diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index f40d01f03b..2afe79e522 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -845,6 +845,79 @@ describe("prMergeAutoSettlementService", () => { ); }); + it("settles a PR that was already merged when first seen, but announces nothing", async () => { + // The machine-switch bug. Point the project tab at another machine and that + // machine reconciles, backfilling rows for PRs it had never stored. Every + // one arrives already merged, with a freshly-minted `randomUUID()` id that + // no `handledPrIds` list can match and a `mergedAt` comfortably after that + // machine's `enabledSince` — so each looked like breaking news, and the user + // got a stack of toasts about PRs that landed days ago. + // + // Filing the sessions is still right; announcing is not. + const db = createMemoryDb(); + const settleSessionsWithOutcome = vi.fn((ids: string[]) => ids); + const emitEvent = vi.fn(); + const service = createPrMergeAutoSettlementService({ + db: db as any, + sessionService: { + list: vi.fn(() => [{ + id: "chat-ready", + toolType: "claude-chat", + archivedAt: null, + settledAt: null, + }]), + settleSessionsWithOutcome, + } as any, + agentChatService: { getSettlementBlockers: vi.fn(async () => []) } as any, + emitEvent, + }); + + // A first snapshot establishes the baseline state, as any running app has. + await service.processSnapshot({ + prs: [createSummary({ id: "pr-existing", githubPrNumber: 1, state: "open" })], + polledAt: "2026-03-24T12:00:00.000Z", + }); + + // Now the tab switches machines and a batch of long-merged PRs appears for + // the first time — merged AFTER enabledSince, so the old guard let them all + // through. + const historic = [ + createSummary({ + id: "pr-977", githubPrNumber: 977, state: "merged", mergedAt: "2026-03-24T12:00:30.000Z", + }), + createSummary({ + id: "pr-983", githubPrNumber: 983, state: "merged", mergedAt: "2026-03-24T12:00:40.000Z", + }), + ]; + await service.processSnapshot({ + prs: historic, + polledAt: "2026-03-24T12:05:00.000Z", + }); + + expect(settleSessionsWithOutcome).toHaveBeenCalledTimes(2); + expect(emitEvent).not.toHaveBeenCalled(); + + // And a merge we actually watch still announces itself, so the fix does not + // simply mute the feature. + const watched = createSummary({ + id: "pr-991", githubPrNumber: 991, state: "open", + }); + await service.processSnapshot({ + prs: [...historic, watched], + polledAt: "2026-03-24T12:06:00.000Z", + }); + await service.processSnapshot({ + prs: [...historic, { ...watched, state: "merged", mergedAt: "2026-03-24T12:07:00.000Z" }], + polledAt: "2026-03-24T12:07:05.000Z", + }); + + expect(emitEvent).toHaveBeenCalledTimes(1); + expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: "pr-sessions-auto-settled", + prNumber: 991, + })); + }); + it("honors a concurrent disable while blocker checks are in flight", async () => { const db = createMemoryDb(); let releaseBlockerCheck: (() => void) | null = null; diff --git a/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts b/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts index 869b8f5cc3..8833833148 100644 --- a/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts +++ b/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts @@ -25,6 +25,31 @@ export function createPrMergeAutoSettlementService(args: { agentChatService: Pick, "getSettlementBlockers">; emitEvent: (event: PrEventPayload) => void; }) { + /** + * The currently open or draft PRs in the previous snapshot, so a merge we + * WATCHED can be told apart from one that was already history when it arrived. + * + * `handledPrIds` cannot answer this. It is keyed by `pr.id`, which the GitHub + * backfill mints with `randomUUID()` per machine — so the same PR carries + * different ids in different machines' databases and the list never matches + * across them. Nor can `enabledSince`: it asks "did this merge after we turned + * the feature on", which is true of every PR merged in the last several weeks. + * + * Together those made switching the project tab to another machine announce + * its entire merge history: that machine reconciles, backfills rows for PRs it + * had never stored, and every one of them looks brand new and freshly merged. + * + * Deliberately in memory. Restarting ADE means we did not watch anything, so + * treating the first snapshot as history is the correct answer, not a lost + * one — the same conclusion `lifecycleNotificationKind` reaches when it + * returns null for a first-sight merged PR. + */ + // The polling snapshot includes every PR stored for the project, including + // terminal history. Only currently open or draft PRs can later produce a + // merge transition, so retain that bounded watch set instead of every state + // we have ever observed. + const previouslyWatchablePrIds = new Set(); + const processSnapshot = async ({ prs, polledAt, @@ -32,6 +57,16 @@ export function createPrMergeAutoSettlementService(args: { prs: PrSummary[]; polledAt: string; }): Promise => { + // Captured before anything can mutate it, and updated only at the end of a + // successful pass, so a snapshot that returns early does not silently + // consume its own evidence. + const previouslyWatchedPrIds = new Set(previouslyWatchablePrIds); + const rememberSnapshot = () => { + previouslyWatchablePrIds.clear(); + for (const pr of prs) { + if (pr.state === "draft" || pr.state === "open") previouslyWatchablePrIds.add(pr.id); + } + }; const settings = getSessionLifecycleSettings(args.db); const state = getPrMergeAutoSettlementState(args.db); if (!state) { @@ -41,9 +76,13 @@ export function createPrMergeAutoSettlementService(args: { now: polledAt, enabled: settings.autoSettleLaneSessionsOnPrMerge, }); + rememberSnapshot(); + return; + } + if (!settings.autoSettleLaneSessionsOnPrMerge || !state.enabledSince) { + rememberSnapshot(); return; } - if (!settings.autoSettleLaneSessionsOnPrMerge || !state.enabledSince) return; const enabledSince = state.enabledSince; const candidates = prs.filter( @@ -103,7 +142,15 @@ export function createPrMergeAutoSettlementService(args: { }); } - if (settledSessionIds.length > 0) { + // Settling still happens for a PR we are meeting for the first time — + // its sessions really are finished and should be filed. Announcing it + // does not: "PR #977 merged" is news only if you did not already know, + // and a PR that was merged before we ever laid eyes on it is history. + // + // Gated here rather than in the toast so every consumer inherits it — + // desktop toasts, mobile push, and anything added later. + const watchedItMerge = previouslyWatchedPrIds.has(pr.id); + if (settledSessionIds.length > 0 && watchedItMerge) { args.emitEvent({ type: "pr-sessions-auto-settled", timestamp: polledAt, @@ -116,6 +163,7 @@ export function createPrMergeAutoSettlementService(args: { }); } } + rememberSnapshot(); }; return { diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index 6019b2ef38..e9ac505ae8 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -17,6 +17,7 @@ import { SESSION_TONE_TEXT_CLASS, } from "../../../shared/sessionStatusPresentation"; import { useAppStore } from "../../state/appStore"; +import { THIS_MACHINE_ID } from "../../../shared/machineIdentity"; /** Surfaces the router location so navigation assertions read the real URL. */ function LocationProbe() { @@ -1030,13 +1031,122 @@ describe("CommandPalette", () => { expect( document.querySelectorAll('[data-thread-id="session-1"]'), ).toHaveLength(1); - // The deduped row keeps the LOCAL identity — no machine marker, no - // binding — so picking it takes the synchronous path. - expect( - document - .querySelector('[data-thread-id="session-1"]') - ?.dataset.machineId, - ).toBeUndefined(); + // The deduped row keeps the LOCAL identity — so picking it takes the + // synchronous path. It is attributed to this Mac rather than to nothing + // (that is what makes it findable by machine name), and attribution is + // precisely what withholds the marker: a badge means "not here". + const row = document.querySelector('[data-thread-id="session-1"]'); + expect(row?.dataset.machineId).toBe(THIS_MACHINE_ID); + expect(row?.dataset.machineOnline).toBeUndefined(); + expect(row?.querySelector("[data-machine-marker-mode]")).toBeNull(); + }); + + it("finds a remote-bound tab's own threads by that machine's name", async () => { + // The payoff of attributing the tab's own sessions. The scorer has always + // matched machine names; those entries simply had no machine to match, + // so with the tab bound to the Studio, typing "studio" found every + // thread on every OTHER machine and none of the ones right in front of + // you. + // A remote-bound tab keys its caches by the BINDING key, not the root + // path — see `selectActiveProjectStateKey`. + const boundKey = "remote:target-studio:project-a"; + seedStore({ + projectBinding: { + kind: "remote", + key: boundKey, + targetId: "target-studio", + runtimeName: "Arul's Mac Studio", + projectId: "project-a", + rootPath: PROJECT_ROOT, + displayName: "Repo A", + }, + lanes: [makeLane()], + sessionsCacheByProject: { + [boundKey]: [makeSession({ id: "session-1", title: "Audit rebase settings" })], + }, + workViewByProject: { [boundKey]: { activeItemId: null } }, + crossMachineLanesByMachineId: { + "target-studio": makeForeignMachine({ online: false, sessions: [] }), + }, + }); + + render( + + + , + ); + + await screen.findByText("Recent threads"); + fireEvent.change( + screen.getByPlaceholderText("Search commands, projects, and threads…"), + { target: { value: "studio" } }, + ); + + const row = await waitFor(() => { + const found = document.querySelector('[data-thread-id="session-1"]'); + expect(found).toBeTruthy(); + return found!; + }); + // Found by machine, and marked as elsewhere — the tab points at the + // Studio but the app is running on this Mac. + expect(row.dataset.machineId).toBe("target-studio"); + expect(row.dataset.machineOnline).toBe("false"); + expect(row.dataset.dimmed).toBe("true"); + expect(row.querySelector("[data-machine-marker-mode]")).toBeTruthy(); + }); + + it("uses the bound target connection when its retained Work slice is absent", async () => { + const boundKey = "remote:target-studio:project-a"; + seedStore({ + projectBinding: { + kind: "remote", + key: boundKey, + targetId: "target-studio", + runtimeName: "Arul's Mac Studio", + projectId: "project-a", + rootPath: PROJECT_ROOT, + displayName: "Repo A", + }, + lanes: [makeLane()], + sessionsCacheByProject: { + [boundKey]: [makeSession({ id: "session-1", title: "Audit rebase settings" })], + }, + workViewByProject: { [boundKey]: { activeItemId: null } }, + // The work-union refresh is still in flight, so it has not retained + // a lane slice for the bound target yet. + crossMachineLanesByMachineId: {}, + }); + globalThis.window.ade.remoteRuntime = { + getConnectionSnapshot: vi.fn(async () => ({ + connectedCount: 0, + updatedAt: Date.now(), + connections: [{ + target: { id: "target-studio", name: "Arul's Mac Studio" }, + state: "error", + projects: [], + lastError: "Connection lost", + lastAttemptedAt: Date.now(), + connectedAt: null, + arch: null, + version: null, + }], + })), + onConnectionSnapshotChanged: vi.fn(() => () => {}), + } as any; + + render( + + + , + ); + + const row = await waitFor(() => { + const found = document.querySelector('[data-thread-id="session-1"]'); + expect(found?.dataset.machineOnline).toBe("false"); + return found!; + }); + expect(row.dataset.dimmed).toBe("true"); + expect(row.querySelector("[data-machine-marker-mode]")).toBeTruthy(); }); }); }); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index 9967f6ac0e..edcd65ef34 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -305,6 +305,17 @@ export function CommandPalette({ // mount, and the palette is mounted for the whole session. Whatever the Work // tab's sync has populated is what we search. const foreignMachines = useRootAppStore((s) => s.crossMachineLanesByMachineId); + /** + * Which machine owns the tab's own sessions and lanes. Remote-bound tabs make + * that another Mac, and without saying so those threads look local: unmarked + * in the results, and unfindable by their machine's name. + * + * Memoized on the two primitives rather than on `projectBinding`, which is a + * fresh object across store writes and would rebuild the whole lowercased + * thread index on every one. + */ + const boundTargetId = projectBinding?.kind === "remote" ? projectBinding.targetId : null; + const boundRuntimeName = projectBinding?.kind === "remote" ? projectBinding.runtimeName : null; const [mode, setMode] = useState("default"); const [actionOutcome, setActionOutcome] = @@ -339,6 +350,22 @@ export function CommandPalette({ }, [], ); + const activeMachine = useMemo( + () => { + if (!boundTargetId || !boundRuntimeName) return null; + const connection = remoteSnapshot?.connections.find( + (candidate) => candidate.target.id === boundTargetId, + ); + return { + machineId: boundTargetId, + machineName: boundRuntimeName, + // A remote-bound tab has no local fallback: before its Work slice + // arrives, the target connection is the only honest liveness source. + online: connection?.state === "connected", + }; + }, + [boundRuntimeName, boundTargetId, remoteSnapshot], + ); const listRef = useRef(null); const browseRequestRef = useRef(0); const detailRequestRef = useRef(0); @@ -835,7 +862,7 @@ export function CommandPalette({ // Built once per session/lane list, not per keystroke — the palette can be // opened against hundreds of sessions and the lowercasing is the expensive // half of the match. - const threadIndex = useThreadIndex(threadSessions, lanes, foreignMachines); + const threadIndex = useThreadIndex(threadSessions, lanes, foreignMachines, activeMachine); const threadMatches = useMemo( () => open && mode === "default" ? rankThreads(threadIndex, trimmedQuery) : [], diff --git a/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx index 8268207deb..9e47f9002d 100644 --- a/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx +++ b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx @@ -21,6 +21,7 @@ import type { } from "../../../shared/types"; import type { CrossMachineMachineLanes } from "../../state/appStore"; import type { CrossMachineLaneMarker } from "../../state/crossMachineLanes"; +import { THIS_MACHINE_ID, THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { LaneMachineMarker } from "../terminals/LaneMachineMarker"; import { SESSION_TONE_DOT_CLASS, @@ -56,12 +57,12 @@ export type ThreadIndexEntry = { /** Lane branch ref, when the lane is known. Shown with a leading `#`. */ branch: string | null; /** - * Cross-machine identity. Null for threads on the machine this tab is bound - * to; set for every foreign row, which is marked and (when the machine is - * unreachable) receded rather than hidden. + * Cross-machine identity. Every thread is attributed to its owner; this Mac + * stays unmarked while foreign rows are marked and (when unreachable) + * receded rather than hidden. */ - machineId: string | null; - machineName: string | null; + machineId: string; + machineName: string; machineOnline: boolean; /** Routing target for a foreign thread — the machine's project binding. */ binding: OpenProjectBinding | null; @@ -97,8 +98,8 @@ function recencyRank(session: TerminalSessionSummary): number { function makeEntry(args: { session: TerminalSessionSummary; lane: LaneSummary | null; - machineId: string | null; - machineName: string | null; + machineId: string; + machineName: string; machineOnline: boolean; binding: OpenProjectBinding | null; }): ThreadIndexEntry { @@ -115,7 +116,7 @@ function makeEntry(args: { titleLower: (args.session.title ?? "").toLowerCase(), laneNameLower: laneName.toLowerCase(), branchLower: (branch ?? "").toLowerCase(), - machineNameLower: (args.machineName ?? "").toLowerCase(), + machineNameLower: args.machineName.toLowerCase(), recencyMs: recencyRank(args.session), }; } @@ -136,8 +137,30 @@ export function buildThreadIndex( sessions: readonly TerminalSessionSummary[], lanes: readonly LaneSummary[], foreignMachines: Readonly> = {}, + /** + * The machine that owns `sessions` and `lanes` — the tab's binding, which is + * NOT necessarily this Mac. Omit for a locally-bound tab. + * + * These entries used to be hardcoded to a null machine on the assumption that + * the bound machine is the one you're sitting at. Bind the tab to another Mac + * and every thread on it went unattributed: no marker on the row, and — since + * the scorer matches on `machineNameLower` — no way to find it by typing that + * machine's name either. + */ + activeMachine: { + machineId: string; + machineName: string; + /** Live target status from the remote-runtime connection snapshot. */ + online: boolean; + } | null = null, ): ThreadIndexEntry[] { const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + // A remote-bound tab can render before the Work union retains that machine's + // lane slice. Keep a retained slice's state, otherwise use the target's live + // connection snapshot; only unbound (local) tabs are presumed online. + const activeMachineOnline = activeMachine + ? (foreignMachines[activeMachine.machineId]?.online ?? activeMachine.online) + : true; const seen = new Set(); const entries: ThreadIndexEntry[] = []; @@ -147,9 +170,9 @@ export function buildThreadIndex( makeEntry({ session, lane: laneById.get(session.laneId) ?? null, - machineId: null, - machineName: null, - machineOnline: true, + machineId: activeMachine?.machineId ?? THIS_MACHINE_ID, + machineName: activeMachine?.machineName ?? THIS_MACHINE_NAME, + machineOnline: activeMachineOnline, binding: null, }), ); @@ -258,10 +281,15 @@ export function useThreadIndex( sessions: readonly TerminalSessionSummary[], lanes: readonly LaneSummary[], foreignMachines: Readonly>, + activeMachine: { + machineId: string; + machineName: string; + online: boolean; + } | null = null, ): ThreadIndexEntry[] { return useMemo( - () => buildThreadIndex(sessions, lanes, foreignMachines), - [sessions, lanes, foreignMachines], + () => buildThreadIndex(sessions, lanes, foreignMachines, activeMachine), + [sessions, lanes, foreignMachines, activeMachine], ); } @@ -318,13 +346,20 @@ export const ThreadResultRow = React.memo(function ThreadResultRow({ const time = relativeTimeCompact( session.lastActivityAt ?? session.settledAt ?? session.startedAt, ); - // Foreign rows carry the same marker the sidebar puts on a foreign lane. Its - // amber tower is IDENTITY, not status — it lives in the context line and - // never in the status slot above, so it cannot be read as an attention call. - // `mode: "name"` because the palette has no lane header to disambiguate a - // bare glyph against. + // Rows that are not on this Mac carry the same marker the sidebar puts on a + // foreign lane. Its amber tower is IDENTITY, not status — it lives in the + // context line and never in the status slot above, so it cannot be read as an + // attention call. + // + // `mode: "name"` is a deliberate exception to the sidebar's glyph-only rule: + // there, a badge is read against neighbouring rows under a lane header that + // groups them. A palette result has neither, so a bare glyph would raise the + // question it exists to answer. + // + // Gated on the MACHINE, not on whether the entry knows its name: every entry + // knows that now, including the ones on this Mac, which must stay unmarked. const machineMarker: CrossMachineLaneMarker | null = - entry.machineId && entry.machineName + entry.machineId !== THIS_MACHINE_ID ? { machineId: entry.machineId, machineName: entry.machineName, @@ -365,7 +400,7 @@ export const ThreadResultRow = React.memo(function ThreadResultRow({ )} onMouseEnter={() => onHover(index)} onClick={() => onActivate(entry)} - data-machine-id={entry.machineId ?? undefined} + data-machine-id={entry.machineId} data-machine-online={ machineMarker ? (entry.machineOnline ? "true" : "false") : undefined } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 9bca501440..16e3486c93 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -8,6 +8,7 @@ import type { NormalizedLinearIssue, OpenProjectBinding, } from "../../../shared/types"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { AgentChatComposer } from "./AgentChatComposer"; import { useAppStore } from "../../state/appStore"; @@ -2422,5 +2423,123 @@ describe("AgentChatComposer", () => { } }); + /** + * The running chat's machine label. PR #968 redesigned the new-chat composer + * and deleted this chip along the way, leaving no way to tell where a chat was + * executing while typing into it. These lock the restored contract. + */ + describe("machine chip", () => { + const chip = (container: HTMLElement) => + container.querySelector('[data-chat-composer-machine-chip="readonly"]'); + + it("names the remote machine a running chat is pinned to", () => { + const { container } = renderComposer({ + sessionId: "session-1", + composerMachineBinding: { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Arul's Mac Studio", + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + }, + }); + + expect(chip(container)?.textContent).toContain("Arul's Mac Studio"); + expect(chip(container)?.getAttribute("aria-label")).toBe("Running on Arul's Mac Studio"); + }); + + it("truncates a long remote machine label while retaining its full accessible name", () => { + const machineName = "Arul's always-on Mac Studio in the office rack"; + const { container } = renderComposer({ + sessionId: "session-1", + composerMachineBinding: { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: machineName, + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + }, + }); + + const element = chip(container)!; + expect(element.getAttribute("aria-label")).toBe(`Running on ${machineName}`); + expect(element.querySelector("span")?.className).toContain("max-w-24"); + expect(element.querySelector("span")?.className).toContain("truncate"); + }); + + it("names this Mac when a running chat has no remote binding", () => { + // A null binding is not "unknown" — it is local. The composer is one row + // with nothing to contrast against, so unlike the sidebar it states the + // machine even when the answer is "here". + const { container } = renderComposer({ + sessionId: "session-1", + composerMachineBinding: null, + }); + + expect(chip(container)?.textContent).toContain(THIS_MACHINE_NAME); + }); + + it("names the remote machine for a running orchestration lead", () => { + const { container } = renderComposer({ + sessionId: "lead-session", + orchestrationRole: "lead", + composerMachineBinding: { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Arul's Mac Studio", + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + }, + }); + expect(screen.queryByRole("button", { name: /Select model/i })).toBeNull(); + expect(chip(container)?.textContent).toContain("Arul's Mac Studio"); + expect(chip(container)?.getAttribute("aria-label")).toBe("Running on Arul's Mac Studio"); + }); + + it("names the remote machine when the host hides model controls", () => { + const { container } = renderComposer({ + sessionId: "embedded-session", + hideModelControls: true, + composerMachineBinding: { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Arul's Mac Studio", + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + }, + }); + + expect(screen.queryByRole("button", { name: /Select model/i })).toBeNull(); + expect(chip(container)?.textContent).toContain("Arul's Mac Studio"); + expect(chip(container)?.getAttribute("aria-label")).toBe("Running on Arul's Mac Studio"); + }); + + it("is read-only — moving a chat is the handoff flow's job, not a picker", () => { + const { container } = renderComposer({ + sessionId: "session-1", + composerMachineBinding: null, + }); + + const element = chip(container)!; + expect(element.tagName).toBe("SPAN"); + expect(element.closest("button")).toBeNull(); + expect(element.getAttribute("aria-haspopup")).toBeNull(); + }); + + it("stays out of a draft composer, where the launch shelf owns the choice", () => { + // A draft has a CHOICE of machine, not a machine. Labelling it here would + // either duplicate the shelf's picker or assert a default about to change. + const { container } = renderComposer({ sessionId: null }); + expect(chip(container)).toBeNull(); + }); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index c4440d2624..3322dfaabd 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DeviceMobile, DotsThree, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; +import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DesktopTower, DeviceMobile, DotsThree, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; import { inferAttachmentType, @@ -48,6 +48,7 @@ import { type ComposerTrigger, } from "../../../shared/composerTriggers"; import { cn } from "../ui/cn"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { PermissionModePicker, type PermissionModePickerOption, @@ -522,6 +523,53 @@ const COMPOSER_TOOLBAR_PICKER_TRIGGER = "max-w-[min(9.5rem,34vw)] shrink min-w-0 // has given up its width. const COMPOSER_MODEL_TRIGGER = "max-w-[min(9.5rem,34vw)] shrink min-w-[4.5rem]"; +/** + * Which machine this chat is executing on, next to the model and thinking-level + * pills. + * + * Read-only by design. A chat is pinned to the machine that owns its lane, so + * there is nothing here to pick — moving it is a real operation with real + * consequences for the worktree and the transcript, and it already has a home + * in Chat actions → Handoff → Continue on another machine. The tooltip points + * there rather than pretending this label is a control. + * + * Shown for LOCAL chats too, which is a deliberate departure from the sidebar, + * where absence of a badge means "this is here". That reading only works in a + * list, where badged and unbadged rows sit next to each other. A composer is one + * row with nothing to contrast against, so a missing label reads as missing + * rather than as an answer — and "where is this actually running" is a question + * worth answering while you are typing into it. + */ +function ComposerMachineChip({ machineName }: { machineName: string }) { + return ( + + + {/* Amber tower, the same identity mark the sidebar badge and the session + hover card use. Bare here — no pill — because the composer toolbar's + other controls are already bordered and a third box would read as a + fourth button. */} + + {machineName} + + + ); +} + const COMPOSER_PERMISSION_TRIGGER_CLASS = cn( "ade-chat-composer-permission-trigger", "inline-flex h-6 min-w-0 shrink-0 items-center justify-start gap-1 rounded-md border px-1.5", @@ -3148,6 +3196,22 @@ export function AgentChatComposer({ ? parallelModelSlots[parallelConfiguringIndex]?.fastMode === true : fastMode === true; + /* Where this chat executes. + + Running chats only. A draft has no machine yet — it has a CHOICE of one, and + the launch shelf's machine picker owns that; a label there would either + duplicate the picker or state a default the user is about to change. + + `composerMachineBinding` is the same binding attachments and the prompt + stash are pinned to, so this label can never disagree with where a file you + drop actually lands. Its absence is meaningful rather than unknown: a chat + with no remote binding is running on this Mac. */ + const composerMachineName = sessionId + ? composerMachineBinding?.kind === "remote" + ? composerMachineBinding.runtimeName + : THIS_MACHINE_NAME + : null; + const claudeSelectionMode = cpmUse === "plan" || im === "plan" ? "plan" : cpmUse ?? "default"; @@ -4743,6 +4807,13 @@ export function AgentChatComposer({ /> ) : null} + {/* Right of the thinking-level selector when controls are present: + model, then how hard it thinks, then where it runs. It stays + outside that conditional because orchestration leads and host + surfaces hide model controls without hiding a running chat. */} + {composerMachineName ? ( + + ) : null} { />, ); - const marker = container.querySelector("[data-session-machine]") as HTMLElement; + // A runtime pin alone says WHICH machine, never that the machine is + // elsewhere — so on its own it paints no badge. The badge is the union + // resolver's verdict, handed down as `machineMarker`. + expect(container.querySelector("[data-session-machine]")).toBeNull(); + + const { container: badged } = render( + , + ); + const marker = badged.querySelector("[data-session-machine]") as HTMLElement; expect(marker.getAttribute("data-session-machine")).toBe("Mac Studio"); - expect(container.querySelector("[data-session-status-slot]")?.contains(marker)).toBe(false); + expect(marker.getAttribute("aria-label")).toBe("On Mac Studio"); + // Identity, not status: it sits beside the status slot, never inside it. + expect(badged.querySelector("[data-session-status-slot]")?.contains(marker)).toBe(false); }); - it("labels a local runtime pin with the machine, not the project display name", () => { + it("never badges a lane that is on this Mac, but still names it in the detail card", () => { + // The whole vocabulary of the badge is "this work isn't here", so a local + // binding earns no glyph however it is pinned. The fact is not lost — the + // hover card still answers it, and still says the MACHINE rather than the + // project folder name. + vi.useFakeTimers(); const { container } = render( { />, ); + expect(container.querySelector("[data-session-machine]")).toBeNull(); + openRowTooltip(container); + expect(hoverRow("machine").textContent).toContain("This Mac"); + expect(hoverRow("machine").textContent).not.toContain("t3code-6754bb34"); + }); + + it("dims the badge for a lane whose machine has gone unreachable", () => { + const { container } = render( + , + ); + const marker = container.querySelector("[data-session-machine]") as HTMLElement; - expect(marker.getAttribute("data-session-machine")).toBe("This Mac"); - expect(marker.textContent).not.toContain("t3code-6754bb34"); + // Offline changes the COLOUR, never the shape: a badge that grew a name when + // a machine dropped would reflow the row for an invisible reason. + expect(marker.getAttribute("data-machine-online")).toBe("false"); + expect(marker.getAttribute("data-machine-marker-mode")).toBe("glyph"); + expect(marker.getAttribute("aria-label")).toBe("On Mac Studio, offline"); + expect(marker.textContent).toBe(""); }); it("drops the machine chip when the lane header already names the machine — but keeps the fact", () => { diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index a8387ea043..a82aeb38f0 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -23,6 +23,7 @@ import type { TerminalSessionSummary, } from "../../../shared/types"; import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; +import type { CrossMachineLaneMarker } from "../../state/crossMachineLanes"; import type { OrchestrationRole } from "../../../shared/types/orchestration"; import { canonicalInputFromSummary, @@ -296,6 +297,7 @@ export const SessionCard = React.memo(function SessionCard({ githubStack = null, showLaneIdentity = false, lanePr = null, + machineMarker = null, suppressMachineChip = false, }: { session: TerminalSessionSummary; @@ -332,6 +334,15 @@ export const SessionCard = React.memo(function SessionCard({ * divider owns the PR badge and a second copy per row would be noise. */ lanePr?: PrSummary | null; + /** + * The machine this row's lane lives on, when that is not the Mac you're + * sitting at. Resolved once by the cross-machine union and handed down, so a + * card and the lane header above it can never disagree about where work is. + * + * Set for the singleton/headerless form, where this card IS the lane header. + * Foreign cards under a real header get `suppressMachineChip` instead. + */ + machineMarker?: CrossMachineLaneMarker | null; /** * The lane header above already names the machine, so the row's own chip * would just repeat it. Set by SessionListPane for children of a lane group @@ -447,11 +458,20 @@ export const SessionCard = React.memo(function SessionCard({ ALWAYS muted with `BranchIcon`. Colour is the only thing that tells the two concepts apart at a glance, so a muted lane name or an accented branch is a bug, not a style choice. */ - const machineName = runtimePin - ? runtimePin.kind === "remote" - ? runtimePin.runtimeName - : THIS_MACHINE_NAME - : null; + /* Two different facts, and they must not be conflated: + - `machineName` is WHICH machine, for the hover card. Any row that knows its + runtime can answer it, including one on this very Mac. + - `machineMarker` is whether that machine is ELSEWHERE. Only the union + resolver decides that, and it is the sole gate on the glyph below. + Deriving the glyph from `machineName` would badge this Mac's own lanes + whenever the tab was bound elsewhere — they carry a local `runtimePin` and + are perfectly named, they are just not somewhere else. */ + const machineName = machineMarker?.machineName + ?? (runtimePin + ? runtimePin.kind === "remote" + ? runtimePin.runtimeName + : THIS_MACHINE_NAME + : null); /* The lane's DECLARED branch versus the one the worktree is actually sitting on. They agree for every healthy lane — which is exactly why the row stopped printing the branch unconditionally: the divider directly above already @@ -470,27 +490,52 @@ export const SessionCard = React.memo(function SessionCard({ // are the same branch to git and would otherwise read as a difference. const branchDiffersFromLane = Boolean(branchName) && branchName !== laneBranchName; + /* The machine glyph, pinned to line 1's RIGHT cluster next to the status — + not into `whereParts` on the left, where it used to sit. + + Two reasons it moved. It is the one part of line 1 whose width is fixed, so + on the left it pushed the elastic lane name around by a constant; and a run + of singleton cards down the sidebar now aligns its glyphs in a single + column, which is what makes "three of these are elsewhere, one is here" + readable at a glance instead of row by row. + + Deliberately an inline element rather than `LaneMachineMarker`: that + component wraps itself in its own SmartTooltip with a focusable trigger, and + this row already owns a hover card and a click target. The visual identity + is identical — amber tower, amber pill — and the name is carried by the + hover card's machine row rather than inline, per the glyph-only rule. */ + const machineGlyph = machineMarker && !suppressMachineChip ? ( + + + + ) : null; + const whereParts: React.ReactNode[] = []; - // `suppressMachineChip` only silences the CHIP. `machineName` still feeds the - // tooltip below, because the fact is never the noise — the repetition is. - if (machineName && !suppressMachineChip) { - // Deliberately an inline chip rather than `LaneMachineMarker`: that - // component wraps itself in its own SmartTooltip with a focusable trigger, - // and this row already owns a tooltip and a click target. The visual - // identity — amber tower glyph inside a NEUTRAL chip — is preserved - // exactly; the marker is identity, so it never enters the status slot and - // never borrows the status palette. - whereParts.push( - - - {machineName} - , - ); - } if (session.pinned) { whereParts.push( + {/* Compact rows have no line 1, so this is their only seat for it — + same precedent as `compactLineageGlyph` directly above. */} + {machineGlyph} {gridIndicator} {statusSlot} @@ -938,6 +986,7 @@ export const SessionCard = React.memo(function SessionCard({ {part} ))} + {machineGlyph} {gridIndicator} {statusSlot} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index b63ea00422..5597fbbfa2 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -1121,10 +1121,105 @@ describe("SessionListPane", () => { describe("cross-machine union", () => { afterEach(() => { - useAppStore.setState({ crossMachineLanesByMachineId: {}, crossMachineLaneScopeKey: null }); + useAppStore.setState({ + crossMachineLanesByMachineId: {}, + crossMachineLaneScopeKey: null, + projectBinding: null, + }); resetCrossMachineLaneSyncForTest(); }); + /** + * The reported bug, end to end. + * + * Sitting at the MacBook with the project tab bound to the Mac Studio and no + * other machine contributing rows, NOTHING in the sidebar was badged — the + * Studio's lanes least of all, even though every one of them was somewhere + * else. The union computed their markers correctly and then discarded the + * whole map, because it bailed on "no rows outside the active binding" + * rather than "no rows outside this machine". + */ + it("badges the tab's own lanes when the tab is bound to another machine", () => { + useAppStore.setState({ + projectBinding: { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio (12)", + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + }, + // Deliberately empty: the Studio IS the tab's binding, so it contributes + // no union row. This is exactly the configuration that used to blank + // every badge. + crossMachineLanesByMachineId: {}, + // The union reads the tab's lanes from the STORE, not from the pane's + // props — that slice is what the binding attributes to its machine. + lanes: [makeLane({ id: "lane-studio", name: "Studio Lane" })], + }); + const studioLane = makeLane({ id: "lane-studio", name: "Studio Lane" }); + const first = makeSession({ + id: "session-studio-a", laneId: "lane-studio", laneName: "Studio Lane", title: "First", + }); + const second = makeSession({ + id: "session-studio-b", laneId: "lane-studio", laneName: "Studio Lane", title: "Second", + }); + + const { container } = renderPane({ + lanes: [studioLane], + runningFiltered: [first, second], + allSessionsUnfiltered: [first, second], + sessionsGroupedByLane: new Map([["lane-studio", [first, second]]]), + }); + + const header = container.querySelector('[data-section-id="lane-studio"]')!; + const marker = header.querySelector("[data-machine-marker-mode]"); + expect(marker).toBeTruthy(); + expect(marker?.getAttribute("data-machine-marker-mode")).toBe("glyph"); + expect(marker?.getAttribute("aria-label")).toBe("Mac Studio (12)"); + // Named on the header, so the rows below it do not repeat it. + expect(cardPropsFor("session-studio-a")?.suppressMachineChip).toBe(true); + }); + + it("badges a one-chat lane on the bound machine through its card", () => { + // Same bug, singleton shape — an auto-created lane with a single chat, + // which is the common way work starts. It has no header to hang a badge + // on, and the card's own chip was fed only by foreign rows, so this case + // stayed blank even once the union kept its markers. + useAppStore.setState({ + projectBinding: { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio (12)", + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + }, + crossMachineLanesByMachineId: {}, + lanes: [makeLane({ id: "lane-solo", name: "Solo Lane" })], + }); + const studioLane = makeLane({ id: "lane-solo", name: "Solo Lane" }); + const only = makeSession({ + id: "session-solo", laneId: "lane-solo", laneName: "Solo Lane", title: "Only chat", + }); + + const { container } = renderPane({ + lanes: [studioLane], + runningFiltered: [only], + allSessionsUnfiltered: [only], + sessionsGroupedByLane: new Map([["lane-solo", [only]]]), + }); + + expect(container.querySelector('[data-section-id="lane-solo"]')).toBeNull(); + const badge = container.querySelector( + '[data-session-id="session-solo"] [data-machine-marker-mode]', + ); + expect(badge?.getAttribute("data-session-machine")).toBe("Mac Studio (12)"); + expect(badge?.getAttribute("aria-label")).toBe("On Mac Studio (12)"); + }); + function seedForeignMachine(overrides: Partial = {}) { useAppStore.setState({ crossMachineLanesByMachineId: { @@ -1169,11 +1264,16 @@ describe("SessionListPane", () => { expect(screen.getByText("Chat on the other machine")).toBeTruthy(); expect(document.querySelector('[data-session-id="session-elsewhere"]')).toBeTruthy(); - // One marker, on the foreign lane only — the local lanes stay untouched. + // One marker, for the foreign lane only — the local lanes stay untouched. + // The foreign lane has a single chat, so it renders headerless and its + // card IS the header: the badge lives there, under the same attribute a + // lane header would use. const markers = document.querySelectorAll("[data-machine-marker-mode]"); expect(markers).toHaveLength(1); - const foreignHeader = screen.getByText("Elsewhere Lane").closest(".ade-lane-group-header"); - expect(foreignHeader?.querySelector("[data-machine-marker-mode]")).toBeTruthy(); + const foreignCard = document.querySelector('[data-session-id="session-elsewhere"]'); + expect(foreignCard?.querySelector("[data-machine-marker-mode]")).toBeTruthy(); + expect(foreignCard?.querySelector("[data-session-machine]")?.getAttribute("data-session-machine")) + .toBe("Mac Studio (12)"); const localHeader = screen.getByRole("heading", { name: "Orphaned sessions: Mobile-created lane (1)", }); @@ -1268,14 +1368,23 @@ describe("SessionListPane", () => { projectId: "project-a", }), "Mac Studio (12)", - // A foreign lane always keeps its divider, so its rows carry no lane - // section — the divider is still there to right-click. - undefined, + // This foreign lane holds one chat, so it renders headerless and the + // divider that used to own the lane menu is gone. The card carries the + // lane actions instead — the same rescue a local singleton gets. + expect.objectContaining({ + laneId: "lane-elsewhere", + laneName: "Elsewhere Lane", + }), ); }); - it("offers lane actions from a foreign lane header", () => { - seedForeignMachine(); + it("keeps a foreign lane's divider, and its menu, once it holds two chats", () => { + seedForeignMachine({ + sessions: [ + makeSession({ id: "session-elsewhere", laneId: "lane-elsewhere", laneName: "Elsewhere Lane" }), + makeSession({ id: "session-elsewhere-2", laneId: "lane-elsewhere", laneName: "Elsewhere Lane" }), + ], + }); renderPane(); const header = screen.getByText("Elsewhere Lane").closest( @@ -1288,6 +1397,55 @@ describe("SessionListPane", () => { expect(screen.getByRole("menuitem", { name: "Open in Lanes" })).toBeTruthy(); }); + it("keeps a foreign parent and child roster under its lane header", () => { + const parent = makeSession({ + id: "foreign-chat-parent", + laneId: "lane-elsewhere", + laneName: "Elsewhere Lane", + title: "Foreign parent chat", + }); + const child = makeSession({ + id: "foreign-child-shell", + laneId: "lane-elsewhere", + laneName: "Elsewhere Lane", + title: "Foreign child shell", + toolType: "shell", + ptyId: "foreign-child-pty", + chatSessionId: parent.id, + }); + seedForeignMachine({ sessions: [parent, child] }); + + const { container } = renderPane(); + + // Foreign cards do not yet share the local parent/child nesting renderer, + // so this two-card roster must retain its group header rather than using + // singleton card decorations for both rows. + const header = container.querySelector('[data-section-id="target-studio:lane-elsewhere"]'); + expect(header).toBeTruthy(); + expect(header?.querySelector("[data-machine-marker-mode]")).toBeTruthy(); + expect(screen.getByText("Foreign parent chat")).toBeTruthy(); + expect(screen.getByText("Foreign child shell")).toBeTruthy(); + expect(cardPropsFor(parent.id)?.suppressMachineChip).toBe(true); + expect(cardPropsFor(child.id)?.suppressMachineChip).toBe(true); + expect(cardPropsFor(child.id)?.laneActions).toBeUndefined(); + }); + + it("reaches a headerless foreign lane's menu through its card", () => { + seedForeignMachine(); + const onContextMenu = vi.fn(); + renderPane({ onContextMenu }); + + fireEvent.contextMenu(document.querySelector('[data-session-id="session-elsewhere"]')!); + const laneActions = onContextMenu.mock.calls[0]![4] as { + open: (at: { x: number; y: number }) => void; + }; + // Routed through the FOREIGN lane menu, so its actions stay bound to the + // machine that owns the lane rather than to the active runtime. + act(() => laneActions.open({ x: 40, y: 60 })); + expect(screen.getByRole("menuitem", { name: "Start chat in lane" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Open in Lanes" })).toBeTruthy(); + }); + it("routes foreign shell rows through the owning-runtime selector", () => { seedForeignMachine({ sessions: [ @@ -1396,9 +1554,14 @@ describe("SessionListPane", () => { snoozedUntil: "2099-01-01T00:00:00.000Z", ...overrides, }); - /** The foreign lane's group, addressed by the composite id this pane uses. */ + /** + * The foreign lane's group, addressed by the composite id this pane uses. + * Reads `data-group-id`, not `data-section-id`: these lanes hold one chat + * each and so render headerless, and a headerless group draws no header + * row for a section id to live on. + */ const foreignGroup = (container: HTMLElement) => container.querySelector( - '[data-section-id="target-studio:lane-elsewhere"]', + '[data-group-id="target-studio:lane-elsewhere"]', ); const shelfContains = (container: HTMLElement, shelf: "snoozed" | "settled") => { const header = container.querySelector(`[data-section-id="lane-shelf:${shelf}"]`); @@ -2553,11 +2716,13 @@ describe("SessionListPane visual hierarchy", () => { expect(document.querySelector("[data-machine-marker-mode]")).toBeNull(); }); - it("names this machine on the local Primary once a second Primary is visible", () => { - // Same adaptive rule `LaneMachineMarker` already uses: the name is promoted - // only when a glyph — or here, the lane name and colour — would be - // ambiguous. Every ADE machine has a Primary, so two connected machines - // put two identical-looking rows in one column. + it("separates two Primaries by badging only the one that is elsewhere", () => { + // Every ADE machine has a Primary, so two connected machines put two + // identically-named, identically-purple rows in one column. This used to + // need a bespoke badge naming the LOCAL Primary. It no longer does: under + // the physical-machine rule exactly one Primary on screen can be unbadged + // — the one on the Mac you're sitting at — so presence versus absence + // separates the pair on its own. useAppStore.setState({ crossMachineLanesByMachineId: { "target-studio": { @@ -2592,18 +2757,22 @@ describe("SessionListPane visual hierarchy", () => { const { container } = renderWithPrimary(); + // This machine's Primary: no badge, and no machine name anywhere on it. const localHeader = container.querySelector('[data-section-id="lane-primary"]') as HTMLElement; - const localMarker = localHeader.querySelector("[data-machine-marker-mode]"); - expect(localMarker?.getAttribute("data-machine-marker-mode")).toBe("name"); - expect(within(localHeader).getByText(THIS_MACHINE_NAME)).toBeTruthy(); - // Primary is the one lane whose machine name is always promoted because - // every connected runtime contributes an otherwise identical Primary. - const foreignHeader = container.querySelector( - '[data-section-id="target-studio:lane-primary-studio"]', + expect(localHeader.querySelector("[data-machine-marker-mode]")).toBeNull(); + expect(within(localHeader).queryByText(THIS_MACHINE_NAME)).toBeNull(); + + // The Studio's Primary: badged, in the resting glyph form. Primary is no + // longer an exception to that — its name is on hover like every other + // lane's. This fixture sorts manually, which opts every lane out of the + // headerless form, so the badge sits on the header where it always did. + const foreignGroup = container.querySelector( + '[data-group-id="target-studio:lane-primary-studio"]', ) as HTMLElement; - const foreignMarker = foreignHeader.querySelector("[data-machine-marker-mode]"); - expect(foreignMarker?.getAttribute("data-machine-marker-mode")).toBe("name"); - expect(within(foreignHeader).getByText("Mac Studio (12)")).toBeTruthy(); + const foreignMarker = foreignGroup.querySelector("[data-machine-marker-mode]"); + expect(foreignMarker?.getAttribute("data-machine-marker-mode")).toBe("glyph"); + expect(foreignMarker?.getAttribute("aria-label")).toBe("Mac Studio (12)"); + expect(within(foreignGroup).queryByText("Mac Studio (12)")).toBeNull(); }); it("keeps Primary out of the quiet shelves when everything in it has settled", () => { @@ -2753,9 +2922,11 @@ describe("SessionListPane machine chip suppression", () => { expect(cardPropsFor("session-local-solo")?.suppressMachineChip).toBeFalsy(); }); - it("suppresses the row chip under a local Primary wearing the machine badge", () => { - // Two visible Primaries is exactly when the LOCAL one grows a machine name; - // that badge counts as a machine-labelled header just like the foreign marker. + it("leaves a local Primary's rows unchipped even opposite another machine's Primary", () => { + // The counterpart to "separates two Primaries by badging only the one that + // is elsewhere". The LOCAL Primary carries no badge at all, so there is no + // header label for its rows to repeat — and nothing to suppress. Work that + // is here says so by staying quiet. const foreignPrimary = makeLane({ id: "lane-primary-remote", name: "Primary", laneType: "primary" }); seedMachine(foreignPrimary, [ makeSession({ @@ -2788,10 +2959,15 @@ describe("SessionListPane machine chip suppression", () => { }); const localHeader = container.querySelector('[data-section-id="lane-primary-local"]')!; - expect(localHeader.querySelector("[data-machine-marker-mode]")).toBeTruthy(); - expect(localHeader.textContent).toContain(THIS_MACHINE_NAME); - expect(cardPropsFor("session-primary-a")?.suppressMachineChip).toBe(true); - expect(cardPropsFor("session-primary-b")?.suppressMachineChip).toBe(true); + expect(localHeader.querySelector("[data-machine-marker-mode]")).toBeNull(); + expect(localHeader.textContent).not.toContain(THIS_MACHINE_NAME); + expect(cardPropsFor("session-primary-a")?.suppressMachineChip).toBeFalsy(); + expect(cardPropsFor("session-primary-b")?.suppressMachineChip).toBeFalsy(); + // Unsuppressed but still unbadged: the card renders a glyph only when the + // union hands it a marker, and a lane on this Mac never gets one. + expect( + container.querySelector('[data-session-id="session-primary-a"] [data-machine-marker-mode]'), + ).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 56cb72b54b..f1312131be 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -21,7 +21,6 @@ import { type CrossMachineLaneMarker, type CrossMachineLaneRow, } from "../../state/crossMachineLanes"; -import { THIS_MACHINE_ID, THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { resolveLaneAccentColor } from "../../../shared/laneColorPalette"; import { LaneMachineMarker } from "./LaneMachineMarker"; import { SessionCard } from "./SessionCard"; @@ -570,6 +569,11 @@ function StickyGroupHeader({ onLayoutAnimationComplete={() => setSliding(false)} className={cn("relative", dimmed && "opacity-55")} data-dimmed={dimmed ? "true" : undefined} + /* Addresses the GROUP, headerless or not. `data-section-id` lives on the + header row, so a singleton — which draws no header — could not be found + at all: fine when only local lanes could be headerless, wrong now that + foreign ones can be shelved in that shape too. */ + data-group-id={sectionId} > {dropIndicatorEdge ? (
state.laneDeleteProgressByLaneId); - // Names the machine this tab's own lanes live on — this Mac, unless the tab is - // bound to a remote runtime. Only read for the Primary machine badge. - const projectBinding = useAppStore((state) => state.projectBinding); const keybindings = useAppStore((state) => state.keybindings); const commandPaletteBinding = useMemo( () => getEffectiveBinding(keybindings, "commandPalette.open", "Mod+K"), @@ -1289,9 +1290,9 @@ export const SessionListPane = React.memo(function SessionListPane({ // chip filters the local list applies. Lanes elsewhere with nothing running // stay out of the sidebar — the union is about work in flight, not an inventory. // - // Resolved before the lane ordering below because the Primary machine badge - // counts VISIBLE primaries, foreign ones included, and a lane whose every chat - // has been filtered out renders nothing and so must not count. + // This is the RENDER list. Anything deciding a lane's SHAPE rather than its + // visibility must read `foreignRows` instead, so the column does not reshape + // as the user types (see `headerlessLaneIds`). const visibleForeignRows = useMemo(() => { if (foreignRows.length === 0) return EMPTY_FOREIGN_ROWS; const query = q.trim().toLowerCase(); @@ -1411,39 +1412,13 @@ export const SessionListPane = React.memo(function SessionListPane({ const renderedLaneIds = useMemo(() => orderedLanes.map((lane) => lane.id), [orderedLanes]); - /** - * The Primary machine badge, or null. - * - * Every ADE machine has a Primary, so with two machines connected the sidebar - * shows two lanes called "Primary" in the same purple and neither says which - * machine it is. Foreign ones already answer that through `LaneMachineMarker`; - * the LOCAL one has no marker at all, because "not here" is the only thing the - * marker normally means. - * - * So this follows `LaneMachineMarker`'s own adaptive rule rather than inventing - * a parallel mechanism: promote the machine name only when the row would - * otherwise be ambiguous. One Primary on screen — the overwhelmingly common - * case — is unambiguous and pays nothing. - * - * "This machine" is the machine the tab is BOUND to, which is not necessarily - * this Mac: a remote-bound tab's local list is that runtime's lanes. - */ - const primaryLaneMachineMarker = useMemo((): CrossMachineLaneMarker | null => { - const localPrimaries = orderedLanes.filter((lane) => lane.laneType === "primary").length; - if (localPrimaries === 0) return null; - const foreignPrimaries = visibleForeignRows - .filter((row) => row.lane.laneType === "primary").length; - if (localPrimaries + foreignPrimaries < 2) return null; - const remote = projectBinding?.kind === "remote" ? projectBinding : null; - return { - machineId: remote?.targetId ?? THIS_MACHINE_ID, - machineName: remote?.runtimeName ?? THIS_MACHINE_NAME, - online: true, - mode: "name", - title: remote?.runtimeName ?? THIS_MACHINE_NAME, - sameBranchElsewhere: false, - }; - }, [orderedLanes, projectBinding, visibleForeignRows]); + /* Two Primaries on screen used to need a parallel badge mechanism here: every + ADE machine has a Primary, so two of them are two lanes with the same name + in the same purple, and the LOCAL one had no marker to tell them apart. + Under the physical-machine rule that mechanism is redundant. Exactly one + Primary can ever be unbadged — the one on the Mac you are sitting at — so + presence versus absence separates the pair on its own, and a run of foreign + Primaries separates on hover like every other foreign lane. */ /** * Which bottom shelf a fully-quiet lane files into, or null to stay in place. @@ -1509,30 +1484,68 @@ export const SessionListPane = React.memo(function SessionListPane({ * the card's drag gesture is already claimed by the work-grid DnD. * 4. A pending handoff placeholder counts as a second row, so a lane does * not lose its header for the second it takes the real session to land. - * A pinned lane also keeps its header — the pin glyph lives there. So does a - * Primary that is currently showing a machine badge: the badge hangs off the - * header, and dropping the header would drop the one thing distinguishing two - * identically-named, identically-coloured Primaries. + * A pinned lane also keeps its header — the pin glyph lives there — and so + * does a lane on an unreachable machine, whose folded-shut group treatment has + * nowhere else to live. The machine badge itself does NOT force a header: a + * headerless lane carries it on the card instead (see + * `RenderCardOptions.machineMarker`), so nothing is lost. + * + * This covers BOTH sides of the cross-machine union. Local lanes are keyed by + * lane id and foreign ones by `machineId:laneId`, which is how every other + * per-lane map in this pane is keyed, so callers look up with the same id they + * already hold. Foreign lanes were previously excluded outright, which is why + * a one-chat lane kept its divider on whichever machine the tab was not bound + * to — and why the sidebar visibly reshaped when you switched machines. */ const headerlessLaneIds = useMemo(() => { const ids = new Set(); + // A singleton has no header to grab, and its card's drag gesture is already + // claimed by the work-grid DnD. if (workLaneSortMode === "manual") return ids; - for (const lane of orderedLanes) { - if (workPinnedLaneIdSet.has(lane.id)) continue; - if (lane.laneType === "primary" && primaryLaneMachineMarker) continue; - if ((unfilteredHandoffCountByLaneId.get(lane.id) ?? 0) > 0) continue; - const roster = unfilteredSessionsByLane.get(lane.id) ?? []; + const isHeaderlessRoster = (roster: readonly TerminalSessionSummary[]): boolean => { const rosterIds = new Set(roster.map((session) => session.id)); const topLevel = roster.filter((session) => { const parentId = session.chatSessionId; return !(parentId && parentId !== session.id && rosterIds.has(parentId)); }); - if (topLevel.length === 1) ids.add(lane.id); + return topLevel.length === 1; + }; + for (const lane of orderedLanes) { + if (workPinnedLaneIdSet.has(lane.id)) continue; + if ((unfilteredHandoffCountByLaneId.get(lane.id) ?? 0) > 0) continue; + if (isHeaderlessRoster(unfilteredSessionsByLane.get(lane.id) ?? [])) ids.add(lane.id); + } + // `foreignRows`, NOT `visibleForeignRows`: the latter is already search- and + // chip-filtered, so reading it here would let a three-chat lane collapse to + // the headerless form the moment a query narrowed it to one — the column + // reshaping under the cursor, which is precisely what rule 1 forbids. + for (const row of foreignRows) { + const compositeLaneId = `${row.machineId}:${row.lane.id}`; + // An unreachable machine's lane keeps its header, singleton or not. The + // header is the only thing that can carry the dimmed, folded-shut group + // treatment an offline machine is supposed to get — a headerless lane has + // no toggle and is always open — and "that machine is gone" is precisely + // when its work should stop occupying a prime row. Same shape of exemption + // as a pinned lane: an explicit reason to keep the header wins. + if (!row.online) continue; + // Both pin keys, for the same reason `foreignLaneShelving` checks both: + // this pane addresses a foreign lane by its composite id while the pin + // store only ever writes bare lane ids. + if (workPinnedLaneIdSet.has(compositeLaneId) || workPinnedLaneIdSet.has(row.lane.id)) continue; + // Handoff jobs are local-runtime records, so a foreign group never has one + // — asserted by reading the same map rather than assumed, so this stays + // correct if foreign handoffs ever land. + if ((unfilteredHandoffCountByLaneId.get(compositeLaneId) ?? 0) > 0) continue; + // Foreign cards still render flat: unlike the local path, this renderer + // does not yet fold a parent chat and its spawned shells into one nested + // unit. Keep the header until it does, so singleton-only lane identity, + // marker, and context actions never leak onto each child card. + if (row.sessions.length === 1) ids.add(compositeLaneId); } return ids; }, [ orderedLanes, - primaryLaneMachineMarker, + foreignRows, unfilteredHandoffCountByLaneId, unfilteredSessionsByLane, workLaneSortMode, @@ -1743,6 +1756,15 @@ export const SessionListPane = React.memo(function SessionListPane({ * only place the machine is named at all. */ suppressMachineChip?: boolean; + /** + * The marker for a headerless lane, whose card stands in for the divider. + * + * Handed down rather than re-derived on the card. The card used to infer its + * machine from `runtimePin`, which is only ever set for foreign rows — so a + * singleton lane on the tab's own machine could never show one even when + * that machine was somewhere else entirely. One resolver, one answer. + */ + machineMarker?: CrossMachineLaneMarker | null; }; const renderCardCore = (session: TerminalSessionSummary, options?: RenderCardOptions) => { const isFirst = !sessionItemAnchorEmitted; @@ -1805,6 +1827,7 @@ export const SessionListPane = React.memo(function SessionListPane({ lanePr={options?.lanePr} gridBadge={foreignRow ? null : gridBadgeFor(session.id)} runtimePin={foreignRow?.binding} + machineMarker={options?.machineMarker ?? null} suppressMachineChip={options?.suppressMachineChip} deltaEnabled={!foreignRow} githubStack={isChatToolType(session.toolType) ? sessionPr?.stack ?? null : null} @@ -2182,22 +2205,14 @@ export const SessionListPane = React.memo(function SessionListPane({ const prBadge = primaryPr ? ( navigate(lanePrDeepLinkPath(primaryPr))} /> ) : null; - // Never populated for a lane on this machine — the marker exists only to - // say "this work isn't here". The one exception is a Primary competing with - // another machine's Primary, where naming THIS machine is the only thing - // that disambiguates the pair (see `primaryLaneMachineMarker`). - const resolvedMachineMarker = markersByLaneId.get(lane.id) - ?? (lane.laneType === "primary" ? primaryLaneMachineMarker : null); - const machineMarker = resolvedMachineMarker - ? { - ...resolvedMachineMarker, - mode: lane.laneType === "primary" ? "name" as const : "glyph" as const, - } - : null; - // Both marker sources count: the cross-machine marker and the local-Primary - // badge say the same thing to the reader, so either one makes the rows' - // own machine chips a repetition. A headerless lane keeps its chip — there - // is no header above it doing the naming. + // Never populated for a lane on the Mac you're sitting at — the marker says + // exactly one thing, "this work isn't here", and no lane type is exempt. + // Note this list is the ACTIVE BINDING's lanes, which is not necessarily + // this machine: bind the tab to another Mac and every lane here is marked. + const machineMarker = markersByLaneId.get(lane.id) ?? null; + // A header that names the machine makes every row beneath it a repetition. + // A headerless lane has no such header, so its lone card carries the marker + // itself — handed down explicitly below rather than suppressed here. const suppressMachineChip = Boolean(machineMarker) && !headerless; // A lane already filed into a quiet shelf renders its rows flat: the shelf // states the tier once, for everything under it. @@ -2243,11 +2258,13 @@ export const SessionListPane = React.memo(function SessionListPane({ // which tier it is in. // // The lone card inherits everything the divider would have carried: - // the lane identity, the PR badge, and — via the session context menu - // — the lane menu, which would otherwise have no right-click target. + // the lane identity, the machine marker, the PR badge, and — via the + // session context menu — the lane menu, which would otherwise have no + // right-click target. ? renderCards(list, { showLaneIdentity: true, lanePr: primaryPr, + machineMarker, laneActions: { laneId: lane.id, laneName: lane.name, @@ -2278,23 +2295,17 @@ export const SessionListPane = React.memo(function SessionListPane({ */ const renderForeignLaneGroup = (entry: ForeignLaneEntry) => { const { row, compositeLaneId, quiet, shelf } = entry; - const marker = markersByLaneId.get(compositeLaneId) ?? null; - // Primary is the only lane whose machine NAME is promoted permanently. - // Every machine owns a Primary, so an icon alone leaves otherwise identical - // headers ambiguous. Other lane headers keep the adaptive glyph/name marker - // chosen by the cross-machine union. - const headerMarker: CrossMachineLaneMarker | null = row.lane.laneType === "primary" - ? { - machineId: row.machineId, - machineName: row.machineName, - online: row.online, - mode: "name", - title: row.machineName, - sameBranchElsewhere: marker?.sameBranchElsewhere ?? false, - } - : marker - ? { ...marker, mode: "glyph" } - : null; + // One resolver, one answer — including for Primary, which used to get its + // name spelled out here on the theory that two identically-named Primaries + // are otherwise indistinguishable. Under the physical-machine rule they are + // not: at most one Primary on screen is unbadged, and it is the one on the + // Mac you're sitting at. + const headerMarker = markersByLaneId.get(compositeLaneId) ?? null; + // A one-chat foreign lane collapses into its card exactly like a local one. + // This used to be unreachable for foreign lanes at all, which is why half + // the sidebar drew dividers the other half didn't — and why which half + // flipped when you switched the tab's machine. + const headerless = headerlessLaneIds.has(compositeLaneId); // An offline machine's group folds shut like a quiet one: retained and // inspectable, not presented as live work. Note this is NOT the shelving // test (see `foreignLaneShelving`) — an offline machine's last-reported @@ -2310,10 +2321,26 @@ export const SessionListPane = React.memo(function SessionListPane({ // that exists only elsewhere simply has none; when the local runtime does // know the lane's PR, it is the same lane and the same badge. const primaryPr = selectPrimaryLanePr(row.lane, prsByLaneId.get(row.lane.id) ?? []); - // A foreign group always has a header, so its sessions never repeat machine - // identity. Primary spells the name out on the rail; other lanes retain the - // compact header marker when the cross-machine resolver says one is useful. + // A group WITH a header names the machine there, so its rows never repeat + // it. A headerless group has no such header, so its lone card takes the + // marker instead — the same trade `renderLaneGroup` makes. const cardOptions: RenderCardOptions = { foreignRow: row, suppressMachineChip: true }; + // The lane menu would otherwise have no right-click target once the divider + // is gone. Same rescue `renderLaneGroup` performs for a local singleton, + // routed through the foreign menu so its actions stay binding-aware. + const singletonLaneActions = row.binding + ? { + laneId: row.lane.id, + laneName: row.lane.name, + open: ({ x, y }: { x: number; y: number }) => triggerForeignLaneContextMenu( + row.lane, + row.binding!, + row.machineName, + row.machineId, + { preventDefault: () => {}, clientX: x, clientY: y }, + ), + } + : null; return ( navigate(lanePrDeepLinkPath(primaryPr))} /> @@ -2354,7 +2382,19 @@ export const SessionListPane = React.memo(function SessionListPane({ ) : undefined} > - {shelf + {headerless + // A singleton skips the snoozed/settled tails entirely, and ignores + // shelf flattening — both are ways of labelling a GROUP, and there is + // no group here, just one card that carries its own status. Identical + // to `renderLaneGroup`'s headerless branch, deliberately. + ? renderCards(row.sessions, { + foreignRow: row, + showLaneIdentity: true, + lanePr: primaryPr, + machineMarker: headerMarker, + ...(singletonLaneActions ? { laneActions: singletonLaneActions } : {}), + }) + : shelf // Inside a shelf, and therefore flat — the same rule as // `renderLaneSessionLists`'s `flat` branch: the shelf header already // states the tier for everything under it, so a further per-lane @@ -2540,7 +2580,10 @@ export const SessionListPane = React.memo(function SessionListPane({ ))} {snoozedShelfForeignRows.map((entry) => shelfRow( entry.compositeLaneId, - isShelfRowExpanded(entry.compositeLaneId), + isShelfRowExpanded( + entry.compositeLaneId, + headerlessLaneIds.has(entry.compositeLaneId), + ), renderForeignLaneGroup(entry), ))}
@@ -2564,7 +2607,10 @@ export const SessionListPane = React.memo(function SessionListPane({ ))} {settledShelfForeignRows.map((entry) => shelfRow( entry.compositeLaneId, - isShelfRowExpanded(entry.compositeLaneId), + isShelfRowExpanded( + entry.compositeLaneId, + headerlessLaneIds.has(entry.compositeLaneId), + ), renderForeignLaneGroup(entry), ))} diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts index 437dd38986..f8f3aa6ac2 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts @@ -292,7 +292,7 @@ describe("This Mac counterpart resolution", () => { }); }); -describe("adaptive machine marker", () => { +describe("machine marker", () => { const localRow = () => makeLane({ id: "lane-local", branchRef: "feature/local" }); it("marks only lanes that are not on this machine", () => { @@ -348,7 +348,9 @@ describe("adaptive machine marker", () => { targetId: "target-studio", projectId: "project-a", binding: activeBinding, - online: true, + // The active target stays represented by its primary list, but its + // retained slice remains the reachability source of truth. + online: false, lanes: [activeLane], sessions: [makeSession({ id: "session-duplicate", laneId: activeLane.id })], lastSyncedAtMs: Date.now(), @@ -379,6 +381,7 @@ describe("adaptive machine marker", () => { lane: activeLane, machineId: "target-studio", machineName: "Mac Studio (12)", + online: false, isThisMachine: false, isActiveBinding: true, }); @@ -422,21 +425,23 @@ describe("adaptive machine marker", () => { }); const markers = resolveCrossMachineLaneMarkers(rows); - // A glyph alone cannot say "offline", so the name is always promoted there. + // Offline is carried by `online`, which dims the glyph and the whole row. + // The form does NOT change with it: a marker that grew a name when a machine + // dropped made the row reflow for a reason the reader could not see. expect(markers.get("target-studio:lane-offline")).toMatchObject({ online: false, - mode: "name", + mode: "glyph", }); // Commits stranded on a machine you cannot reach are exactly the ones worth // naming, so its branch still counts toward "same branch elsewhere". expect(markers.get("target-laptop:lane-online")).toMatchObject({ online: true, - mode: "name", + mode: "glyph", sameBranchElsewhere: true, }); }); - it("names machines when two distinct foreign machines are visible at once", () => { + it("keeps the glyph form when two distinct foreign machines are visible at once", () => { const rows = buildCrossMachineLaneRows({ localLanes: [], machines: { @@ -465,11 +470,14 @@ describe("adaptive machine marker", () => { }, }); const markers = resolveCrossMachineLaneMarkers(rows); - expect(markers.get("a:lane-a")?.mode).toBe("name"); - expect(markers.get("b:lane-b")?.mode).toBe("name"); + // Two foreign machines used to promote both names inline. They now stay + // glyphs and are told apart on hover: a resting form that never moves beat + // one that reflowed the column as machines came and went. + expect(markers.get("a:lane-a")).toMatchObject({ mode: "glyph", title: "Mac Studio (12)" }); + expect(markers.get("b:lane-b")).toMatchObject({ mode: "glyph", title: "MacBook Pro (97)" }); }); - it("names the machine when the same branch exists here too", () => { + it("flags a branch held on another machine without changing the marker's form", () => { const rows = buildCrossMachineLaneRows({ localLanes: [makeLane({ id: "lane-local", branchRef: "refs/heads/feature/shared" })], machines: { @@ -487,8 +495,10 @@ describe("adaptive machine marker", () => { }, }); const marker = resolveCrossMachineLaneMarkers(rows).get("a:lane-foreign"); + // Still computed — the push-divergence guard reasons about the same + // condition — but it no longer promotes the name. expect(marker?.sameBranchElsewhere).toBe(true); - expect(marker?.mode).toBe("name"); + expect(marker?.mode).toBe("glyph"); // The name is always reachable, glyph mode included. expect(marker?.title).toBe("Mac Studio (12)"); }); diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.ts b/apps/desktop/src/renderer/state/crossMachineLanes.ts index e3c83f5fd9..649de64a07 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.ts @@ -177,26 +177,46 @@ export type CrossMachineUnion = { }; /** - * The adaptive machine marker. + * The machine marker. * - * Default is a bare monochrome glyph, and only for work that is not here — the - * indicator appears exactly when it carries information. The name is promoted - * into the row when a glyph alone would be ambiguous: the machine is offline, - * two or more distinct foreign machines are on screen at once, or this lane's - * branch also exists on another machine. + * ONE rule, and it is the whole vocabulary of this indicator: a marker exists + * for a lane if and only if that lane is not on the physical Mac this app is + * running on. Absence therefore means "this work is here" — which only reads as + * information because the rule has no exceptions. It is deliberately blind to + * the project tab's binding: the tab can point anywhere, and a badge that moved + * with it would be answering a question nobody asked. * - * The lane accent already owns the color channel, so the marker is monochrome — - * a tinted marker would read as a second, competing lane color. + * The form is always a bare glyph; the name is on hover. An earlier version + * promoted the name inline when a glyph alone looked ambiguous (offline, two + * foreign machines, same branch elsewhere), which meant a row's shape changed + * for reasons the reader could not see. A single resting form that never moves + * beat a cleverer one that did. `mode` survives as a field because the command + * palette renders the name — it has no lane header to disambiguate a glyph + * against — and that exception is worth being explicit about rather than + * implicit in a second component. + * + * The glyph is amber, in an amber pill. Amber is machine identity everywhere in + * ADE (top bar, connections panel, attention center, session hover card), and + * `SessionCard` states the rule directly: amber appears exactly once per row, on + * the machine tower, because that glyph is identity and never status. */ export type CrossMachineLaneMarker = { machineId: string; machineName: string; /** False while the owning machine is unreachable; the row reads as dimmed. */ online: boolean; + /** + * Always `"glyph"` from `resolveCrossMachineLaneMarkers`. Surfaces with no + * lane header of their own (the command palette) override it to `"name"`. + */ mode: "glyph" | "name"; /** Always the machine name — the glyph form still exposes it on hover. */ title: string; - /** True when the same branch exists as a lane on another machine. */ + /** + * True when the same branch exists as a lane on another machine. No longer + * changes the marker's form; the push-divergence guard reasons about the same + * condition and this is the cheapest place to surface it. + */ sameBranchElsewhere: boolean; }; @@ -335,12 +355,19 @@ export function buildCrossMachineLaneRows(input: { ? activeBinding.runtimeName : THIS_MACHINE_NAME; const activeRemoteBinding = activeBinding?.kind === "remote" ? activeBinding : null; + // The active target's retained slice is omitted from the union below to avoid + // duplicate rows, but it is still the source of truth for a dropped target. + // A local binding (and an active target without a retained slice) stays live + // until the connection snapshot has recorded otherwise. + const activeMachineOnline = activeRemoteBinding + ? input.machines[activeRemoteBinding.targetId]?.online ?? true + : true; for (const lane of input.localLanes) { rows.push({ lane, machineId: activeMachineId, machineName: activeMachineName, - online: true, + online: activeMachineOnline, isThisMachine: activeMachineId === THIS_MACHINE_ID, isActiveBinding: true, sessions: [], @@ -396,9 +423,14 @@ export function orderCrossMachineRows( } /** - * Resolves the adaptive marker for every foreign lane row. Local rows get no - * entry at all — "work isn't here" is the only thing the marker communicates, - * so on a single-machine setup this map is empty and the header is untouched. + * Resolves the marker for every lane that is not on this physical Mac. Rows on + * this machine get no entry at all — "work isn't here" is the only thing the + * marker communicates, so on a single-machine setup this map is empty and the + * header is untouched. + * + * Note what this does NOT consult: `isActiveBinding`. A lane on the machine the + * project tab happens to point at is still foreign work if you are sitting at a + * different Mac, and it gets a marker like any other. * * Offline machines are included, and their branches still count toward * "same branch elsewhere": a branch you cannot see right now is exactly the one @@ -410,12 +442,9 @@ export function resolveCrossMachineLaneMarkers( const foreign = rows.filter((row) => !row.isThisMachine); if (foreign.length === 0) return EMPTY_MARKERS; - const distinctForeignMachineIds = new Set(foreign.map((row) => row.machineId)); - const manyForeignMachines = distinctForeignMachineIds.size >= 2; - - // Same-branch-elsewhere: a branch held as a lane on two or more machines. This - // is the same condition the push-divergence guard reasons about, so the - // sidebar names the machine rather than leaving the user to guess which one. + // Same-branch-elsewhere: a branch held as a lane on two or more machines. The + // push-divergence guard reasons about the same condition; carrying it on the + // marker means a consumer that wants to warn does not have to recompute it. const machinesByBranch = new Map>(); for (const row of rows) { const branch = normalizeBranchRef(row.lane.branchRef); @@ -429,8 +458,6 @@ export function resolveCrossMachineLaneMarkers( for (const row of foreign) { const branch = normalizeBranchRef(row.lane.branchRef); const sameBranchElsewhere = (machinesByBranch.get(branch)?.size ?? 0) >= 2; - const mode: CrossMachineLaneMarker["mode"] = - !row.online || manyForeignMachines || sameBranchElsewhere ? "name" : "glyph"; // Active-binding lanes render through the primary lane list, whose key is // the bare lane id. Other machines render through composite union rows. const markerKey = row.isActiveBinding @@ -440,7 +467,10 @@ export function resolveCrossMachineLaneMarkers( machineId: row.machineId, machineName: row.machineName, online: row.online, - mode, + // Always the resting form. Offline is expressed by `online: false`, which + // dims the glyph and the whole row — a shape change on top of that would + // be a second signal for one fact. + mode: "glyph", title: row.machineName, sameBranchElsewhere, }); @@ -1532,12 +1562,29 @@ export function useCrossMachineLaneUnion(active = true): CrossMachineUnion { [localLanes, machines, projectBinding], ); return useMemo(() => { + // Two different questions, and conflating them is what hid every badge when + // the tab was bound to another Mac: + // + // - `isActiveBinding` decides where a row RENDERS. The tab's machine owns + // the primary lane list; everything else renders as a union row. + // - `isThisMachine` decides whether a row is BADGED. That is about the + // physical Mac in front of you and nothing else. + // + // This used to bail on `foreignRows.length === 0` and drop the marker map + // with it. Bind the tab to the Studio while sitting at the MacBook and every + // lane is active-binding, so `foreignRows` is empty — yet every one of those + // lanes is somewhere else and had just earned a marker. The markers were + // computed correctly and then thrown away one line later. const foreignRows = orderCrossMachineRows( rows.filter((row) => !row.isActiveBinding), ); // Single-machine setups take this branch forever: no marker map is built and // the lane header renders exactly as it did before this feature existed. - if (foreignRows.length === 0) return EMPTY_CROSS_MACHINE_UNION; + if (!rows.some((row) => !row.isThisMachine)) { + return foreignRows.length === 0 + ? EMPTY_CROSS_MACHINE_UNION + : { foreignRows, markersByLaneId: EMPTY_MARKERS }; + } return { foreignRows, markersByLaneId: resolveCrossMachineLaneMarkers(rows), diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index ad49f57a42..d832755fee 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -106,7 +106,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/chatTurnState.ts` | Shared renderer turn-state invariant used by cache hydration, history snapshots, live event flushes, and locked-session summary refreshes. A terminal `status`/`done` at the end of the transcript outranks an eventually consistent `status: "active"` session summary, so failed/interrupted turns restore an idle composer. Also resolves the user message associated with a failed turn, including Codex optimistic user rows that predate assignment of a provider `turnId`. | | `apps/desktop/src/renderer/lib/claudeAuthPrompt.ts` | Renderer-side classifier for Claude logged-out / `/login`-required error text. Drives the header and sticky login CTAs; matches both Claude-first wording and ADE's own "Authentication failed for <model>" classified message. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | -| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx`, `DraftMachinePicker.tsx`, `useDraftMachineRouting.ts`, `draftAttachmentTransfer.ts` | Composer UI and draft runtime routing: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, parallel launch slot configuration, and inline smart-link chips. Completed GitHub, Linear, ADE, and generic web URLs become atomic violet chips while their literal URL remains the serialized prompt text; click/keyboard actions offer Copy link and Remove link, hover exposes the canonical URL, and Backspace/Delete removes the whole token. During an active Claude turn, the split Send caret selects inline, after-turn, or interrupt delivery without sending; the primary button and Enter execute the chosen mode. Staged messages expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted, dropped, and native-path attachments are copied through `ade.agentChat.saveTempAttachment` on the draft's selected runtime, so a MacBook chat never receives a Studio-only path (and vice versa). The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. The empty-draft launch shelf separates machine selection (`DraftMachinePicker`) from the lane list, scopes lanes to the chosen machine, and keeps Shell and Import beside the resulting target. It hides the machine control when there is only one choice and preserves Auto-create across machine changes. Attachment storage, model/auth discovery, slash commands, file search, parallel launch state, creation, rollback, and recovery all carry that captured `OpenProjectBinding`; unresolved or disconnected bindings fail closed instead of falling back to the tab's machine. `useDraftMachineRouting` restores the project/tab-selected machine before enabling the composer. When the user changes machines within the same draft scope, `draftAttachmentTransfer` copies pasted/local image bytes from the owning runtime into the newly selected runtime and rewrites their attachment paths; portable image URLs remain unchanged. Non-image files and iOS/App Control/built-in-browser visual context are removed because their paths and ownership cannot move safely. The composer blocks sends while a copy is pending, and a failed copy keeps the source images visible but blocks sending until the user switches back or removes them. A project-tab scope change establishes the restored machine as the attachment owner instead of treating tab hydration as a user-requested transfer. The **This Mac** option resolves to *this repository's* local checkout through `thisMachineProjectRoot.ts` rather than to the first open local tab; when no matching local checkout exists, the composer shows an inline dismissible amber notice. | +| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx`, `DraftMachinePicker.tsx`, `useDraftMachineRouting.ts`, `draftAttachmentTransfer.ts` | Composer UI and draft runtime routing: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, parallel launch slot configuration, and inline smart-link chips. Running chats show a read-only amber tower plus their owning machine name beside the model and thinking controls; moving a chat is the explicit Chat actions → Handoff → Continue on another machine flow. Completed GitHub, Linear, ADE, and generic web URLs become atomic violet chips while their literal URL remains the serialized prompt text; click/keyboard actions offer Copy link and Remove link, hover exposes the canonical URL, and Backspace/Delete removes the whole token. During an active Claude turn, the split Send caret selects inline, after-turn, or interrupt delivery without sending; the primary button and Enter execute the chosen mode. Staged messages expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted, dropped, and native-path attachments are copied through `ade.agentChat.saveTempAttachment` on the draft's selected runtime, so a MacBook chat never receives a Studio-only path (and vice versa). The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. The empty-draft launch shelf separates machine selection (`DraftMachinePicker`) from the lane list, scopes lanes to the chosen machine, and keeps Shell and Import beside the resulting target. It hides the machine control when there is only one choice and preserves Auto-create across machine changes. Attachment storage, model/auth discovery, slash commands, file search, parallel launch state, creation, rollback, and recovery all carry that captured `OpenProjectBinding`; unresolved or disconnected bindings fail closed instead of falling back to the tab's machine. `useDraftMachineRouting` restores the project/tab-selected machine before enabling the composer. When the user changes machines within the same draft scope, `draftAttachmentTransfer` copies pasted/local image bytes from the owning runtime into the newly selected runtime and rewrites their attachment paths; portable image URLs remain unchanged. Non-image files and iOS/App Control/built-in-browser visual context are removed because their paths and ownership cannot move safely. The composer blocks sends while a copy is pending, and a failed copy keeps the source images visible but blocks sending until the user switches back or removes them. A project-tab scope change establishes the restored machine as the attachment owner instead of treating tab hydration as a user-requested transfer. The **This Mac** option resolves to *this repository's* local checkout through `thisMachineProjectRoot.ts` rather than to the first open local tab; when no matching local checkout exists, the composer shows an inline dismissible amber notice. | | `apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx` | Desktop prompt-stash control mounted immediately left of the context meter. Cmd/Ctrl+S and the bookmark share one path: non-empty text is persisted before the exact saved draft is cleared, while an empty draft opens the keyboard-navigable stash menu. Restore is a take operation, but it puts text into the composer before waiting for a remote delete so edits cannot be overwritten; delete failure intentionally favors a duplicate over lost text. Attachments and context items never enter the stash. | | `apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx` | Shared reasoning slider. Supports pointer drag with nearest-tick snap, keyboard arrows/Home/End, a progressive filled gradient, and a directional roll transition for the active tier label, GPT-5.6 labels (Light, Medium, High, Extra High, Max, and Ultra where supported), and an Ultra multi-agent usage note. The collapsed trigger uses full tier names on desktop, keeps abbreviations for narrow/mobile layouts, and does not add a second border around the label. Choosing or dragging to a tier leaves the popover open; outside click or Escape closes it. | | `apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx` | Pending-input card used when ADE asks the user to choose a model for a new or rerouted agent. It renders the agent briefing, touched files, run-after dependencies, provider/model controls, cancel/confirm states, and leaves the model unset until the user chooses one. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 857318e2de..f221628fa9 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -19,7 +19,7 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/renderer/lib/aiDiscoveryCache.ts` | Runtime-binding-scoped AI integration-status and provider-model cache shared across renderer surfaces. Local and remote checkouts with the same project identity cannot share model/auth state. `getAiStatusCached` uses a 10-second freshness window and deduplicates concurrent `ade.ai.getStatus` requests; cache update/invalidation events let open ModelPickers react without polling or mounting their own background refresh loops. | | `CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | Modal state and user flow for **Send to machine**. It verifies a local source lane, follows live remote connection snapshots, lets the user pick brief or full-history fork (fork defaults on for fork-capable providers and constrains the model picker to the same provider), lets the user set the destination chat's model, reasoning effort, fast mode, and permission mode with the same shared pills the composer uses, handles existing-project versus confirmed-clone setup, offers a destination-run fast-forward when the target lane is clean and strictly behind the source commit, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. Source blockers render through `BlockedReasons` / `BlockedActionButton` instead of silently disabling Continue. The pure half — stage/mode types, `SourceCheck`, branch/route/repo-readiness copy, permission tone and icon maps, send-step labels, `CheckRow` — lives in `crossMachineHandoffPresentation.tsx` so it is assertable without mounting the stateful modal. Once destination acceptance is dispatched, a runtime timeout or connection interruption produces an amber unknown-outcome notice: the destination chat may still appear, the user should check that machine before retrying, and the modal never reports a truthful cancellation that the runtime did not perform. A fork that the destination can't accept (older ADE with no `forkHandoffSupport`, oversize history, or an unforkable provider file) surfaces a plain reason and a one-click **send as brief** that re-runs prepare + preflight; the insecure-route consent line is fork-aware (a fork discloses that the full history is sent exactly as recorded, a brief that only the summary is sent). | | `AgentChatMessageList.tsx` | Virtualized message list. The virtualizer is **hand-rolled**, not `@tanstack/react-virtual`: a `measuredHeights` row-key → height `Map` feeds top/bottom spacer divs around the rendered window, and each rendered row is wrapped in `MeasuredEventRow`, whose `ResizeObserver` reports its real height through `handleMeasure` → `reconcileMeasuredScrollTop` so a height correction above the viewport does not shift what the reader is looking at. Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundFinishChip` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. A Claude `queue_recovery: available` row renders one eight-second Undo card; later `restored`/`expired` rows settle the same recovery id so history replay cannot show a stale action. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`; terminal provider capacity/usage-limit errors render `ProviderFailureRecoveryCard` with same-thread retry and model-selection actions. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details, and a handoff-brief user row shows a small brief chip. When a fork seeds pre-fork history into the new chat, the envelopes carry the `handoff_fork` provider origin and the list draws a single `Forked from the previous chat — full history above` divider (`computeForkHistoryDividerRowKey` pins it to the first live row after the seeded history) instead of one marker per seeded row. | -| `AgentChatComposer.tsx`, `ComposerPromptStash.tsx`, `ComposerSmartLinkMenu.tsx`, `smartLinkChipMark.ts` | Text input, attachments, model selector, compact title-only permission controls (per-provider `PermissionModePickerOption` tables fed into the shared `components/shared/PermissionModePicker`, which the composer owns the option data for but not the control), slash commands, desktop prompt stashes, smart-link chips (`smartLinkChipMark.ts` returns the inline `currentColor` SVG brand mark each chip renders), pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. `ComposerPromptStash` keeps its command surface mounted when the Appearance preference hides the bookmark, so Cmd/Ctrl+S remains available, but the visible bookmark stays out of the toolbar while both the composer and stash list are empty. Its menu is rendered in a viewport-clamped body portal with a bounded, scrolling list so composer overflow and short windows cannot crop it. A save first copies up to ten attached images into the owning project runtime, commits the text plus image references, then clears only the unchanged composer snapshot; restore reapplies both text and images before consuming the stash. The list renders an image thumbnail when the active runtime owns the bytes. On another synced runtime, the row retains its text and image count, labels the images as living on another machine, and refuses restore until the composer is connected to the origin runtime. Machine-bound context and non-image files are not stashed. Completed URLs are non-editable inline chips whose `data-composer-chip-text` preserves the literal URL during serialization; clicking or keyboard-activating a chip opens the Copy link / Remove link menu, and character deletion removes the whole URL token. Every chip also carries a kind-naming `data-composer-chip` attribute, and a scoped `selectionchange` effect marks intersecting chips with `data-composer-chip-selected` so the native selection paints continuously across them (overlay styling lives in `apps/desktop/src/renderer/index.css`). During an active Claude turn, its split Send control selects without dispatching among inline, after-turn, and interrupt delivery; the primary button and Enter execute the selected mode. A separate Claude split Stop control selects **Stop & clear queue** or **Stop only**, persists that choice per chat, and dismisses its custom popover immediately after selection. Staged rows expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. It forwards one-shot open requests to the shared ModelPicker so transcript recovery cards can open model selection without synthetic DOM events; the picker acknowledges each request so remounts do not reopen it. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | +| `AgentChatComposer.tsx`, `ComposerPromptStash.tsx`, `ComposerSmartLinkMenu.tsx`, `smartLinkChipMark.ts` | Text input, attachments, model selector, compact title-only permission controls (per-provider `PermissionModePickerOption` tables fed into the shared `components/shared/PermissionModePicker`, which the composer owns the option data for but not the control), slash commands, desktop prompt stashes, smart-link chips (`smartLinkChipMark.ts` returns the inline `currentColor` SVG brand mark each chip renders), pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. A running chat's toolbar also shows a read-only amber tower plus the owning machine name beside the model and thinking controls; it identifies where the chat executes and directs moves to Chat actions → Handoff → Continue on another machine. A draft omits that label because its launch shelf owns the machine choice. `ComposerPromptStash` keeps its command surface mounted when the Appearance preference hides the bookmark, so Cmd/Ctrl+S remains available, but the visible bookmark stays out of the toolbar while both the composer and stash list are empty. Its menu is rendered in a viewport-clamped body portal with a bounded, scrolling list so composer overflow and short windows cannot crop it. A save first copies up to ten attached images into the owning project runtime, commits the text plus image references, then clears only the unchanged composer snapshot; restore reapplies both text and images before consuming the stash. The list renders an image thumbnail when the active runtime owns the bytes. On another synced runtime, the row retains its text and image count, labels the images as living on another machine, and refuses restore until the composer is connected to the origin runtime. Machine-bound context and non-image files are not stashed. Completed URLs are non-editable inline chips whose `data-composer-chip-text` preserves the literal URL during serialization; clicking or keyboard-activating a chip opens the Copy link / Remove link menu, and character deletion removes the whole URL token. Every chip also carries a kind-naming `data-composer-chip` attribute, and a scoped `selectionchange` effect marks intersecting chips with `data-composer-chip-selected` so the native selection paints continuously across them (overlay styling lives in `apps/desktop/src/renderer/index.css`). During an active Claude turn, its split Send control selects without dispatching among inline, after-turn, and interrupt delivery; the primary button and Enter execute the selected mode. A separate Claude split Stop control selects **Stop & clear queue** or **Stop only**, persists that choice per chat, and dismisses its custom popover immediately after selection. Staged rows expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. It forwards one-shot open requests to the shared ModelPicker so transcript recovery cards can open model selection without synthetic DOM events; the picker acknowledges each request so remounts do not reopen it. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | | `apps/desktop/src/main/services/chat/promptStashService.ts` | Runtime-backed prompt-stash persistence. Exact prompt text, up to ten image references, their origin sync-site id, provider/model labels, and creation time are stored in the PK-only, CRR-compatible `prompt_stashes` table; newest-first retention is capped at 20 entries. Text and metadata converge across runtimes. HTTP(S) image references remain portable, while local-image bytes stay on the originating runtime: off-origin readers receive the image count but no absolute paths and cannot consume the stash. Origin-owned image files referenced by live stashes are protected from the normal seven-day temporary-attachment cleanup. | | `ProviderFailureRecoveryCard.tsx` | Friendly recovery surface for terminal provider capacity and usage-limit failures. Shows human-readable error identity and guidance, then offers **Retry turn** and **Choose model** only after the failed turn has released the composer. | | `chatTurnState.ts` | Pure turn-state helpers shared by live and hydration paths. Terminal transcript evidence beats a stale active session summary, and failed-turn retry resolves the associated non-steer user message even when the optimistic row has no provider turn id. | @@ -137,9 +137,11 @@ machine, and the list grew by machine count rather than staying the length of on machine's lanes. Choosing the machine first keeps the lane list flat, short, and scoped — `DraftMachinePicker` renders nothing below two machines, and `AgentChatPane` passes `draftShelfLanes` (bare lane ids for the selected machine) -rather than the machine-qualified option ids the combined selector needed. This -also retired the composer toolbar's machine chip, which only ever existed because -machine had nowhere else to live. +rather than the machine-qualified option ids the combined selector needed. A +draft therefore has no machine label in its toolbar: the shelf is the live +machine control. Once a chat exists, the toolbar instead shows its owning +machine as a read-only execution label; moving it remains Chat actions → Handoff +→ Continue on another machine. `handleMachineChange` in `useDraftMachineRouting.ts` re-points the lane to the target machine's primary **without touching `draftLaunchTargetId`**, so a draft diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 0938b6eed8..bf62612eb9 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -78,8 +78,9 @@ Service files (`apps/desktop/src/main/services/prs/`): |------|---------------| | `prService.ts` | PR CRUD, GitHub sync, merge context, draft descriptions, check/review/comment hydration, cached detail snapshots (`listSnapshots`), commit snapshots (`getCommits`), integration proposals, merge-into-existing-lane adoption, merge bypass, post-merge cleanup, standalone PR branch cleanup (`cleanupBranch`), deployment listing, review-thread reply/resolve/react mutations for the timeline, the aggregate `getMobileSnapshot` that powers the iOS PRs tab, and `listOpenPullRequests` — a paginated `/repos/{owner}/{name}/pulls?state=open` fetch returning `BranchPullRequest[]` for the lane-creation branch picker. `getForLane(laneId)` resolves through `getDisplayCandidateForCurrentLaneBranch`: it returns the best PR whose head branch matches the lane's current branch ref, considering both mapped `pull_requests` rows and unmapped `github_pr_projections` rows (folded in as synthetic `gh:owner/repo#num` summaries with `unmapped: true`), ranked open/draft → merged → closed then most-recently-updated / created / highest PR number, so a freshly merged PR still shows in lane-scoped UI instead of disappearing the moment GitHub flips the state — and a lane whose PR was created outside ADE still badges from the projection alone. A primary lane whose branch equals its base is excluded. `listPrsByLane()` walks `laneService.list` and applies the same candidate selection over one shared read of mapped rows + projection rows. `getGitHubSnapshot` fetches repo PRs, backfills same-repo lane PR rows by branch, and performs a capped per-branch fallback (`head=:`) for active lane branches missing from the repo snapshot window so old merged/closed externally-created PRs can still badge lanes. On PR open, `publishLinearPrCardsForLane` combines the lane's own Linear references with `collectLinearPrIssueReferencesForLaneSessions(laneId)` — issues attached only to a chat/CLI session in the lane (via `laneService.listLinearIssuesForLaneSessions`, authoritative for sessions whose lane mirror never landed) — deduped via `dedupeLinearPrIssueReferences`, so a session-only issue still gets a PR attachment. When the optional live-status round-trip is enabled (`getLinearLiveStatusService`, gated by `ADE_LINEAR_LIVE_STATUS_ROUNDTRIP=1`) it also posts a PR-link comment back to each linked issue. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). `computeStatus` / `getStatusByGithub` fetch the authoritative GitHub merge box over GraphQL (`mergeStateStatus`, `reviewDecision`, required/approving review counts, `viewerPermission` for bypass) and fold it into `PrStatus`; `getStatusByGithub` does the same for unmapped GitHub-tab PRs keyed only on `owner/repo#num` coords. `land` takes an editable commit title/body (`commit_title`/`commit_message`, `--subject`/`--body` on the admin retry; ignored for `rebase`) and an `expectedHeadSha` stale-head guard, and `updateBranch` brings a behind branch up to date via GitHub's `update-branch` API (`merge` strategy) or ADE's local lane rebase + force-with-lease push (`rebase` strategy, conflict-aware). Review-thread reply/resolve/react mutations work on unmapped GitHub-tab PRs through synthetic `gh:owner/repo#num` ids (`parseSyntheticGithubPrId` resolves the repo; `assertThreadBelongsToPr` still verifies thread ownership). Commit rows carry an avatar URL — the linked GitHub avatar when present, else a Gravatar identicon derived from the commit-author email. `reconcileOnFocus({ force? })` is the catch-up safety net for the pollerless brain (in-memory 90 s throttle + single-flight, bounded merged-heal, 30-min `state:"all"` closed-sweep) and `syncLanePr(laneId)` is the manual per-badge sync; both heal merged/unmapped lane PRs and emit a `pr-reconcile` event. See [Keeping PR status fresh](#keeping-pr-status-fresh). | | `prService.test.ts` | Feature-level service coverage, including mobile snapshot aggregation, paged GitHub history and exact state totals, webhook invalidation, unmapped mobile detail, and integration proposal behavior. | -| `prAsync.test.ts` | Shared bounded-concurrency and async helper coverage. | +| `prAsync.test.ts` | Shared bounded-concurrency and async helper coverage, plus the `prMergeAutoSettlementService` regression suite. | | `prPollingService.ts` | 60 s fallback polling loop, fingerprint-based change detection, notification emission, targeted webhook reconciliation, and GitHub rate-limit backoff. `reconcilePrs(prIds)` coalesces webhook-linked PR ids and refreshes only those rows immediately; ordinary `poke()` still requests a normal tick. User-driven hot windows poll affected PRs every 5 s for the first minute and 15 s until the three-minute cap, but poll results cannot start or restart a hot window. Writes `last_polled_at` per PR so callers can run delta polls on the next tick. The ADE daemon owns an instance (created + started + disposed in `apps/ade-cli/src/bootstrap.ts`) so background polling and PR events run for runtime-bound windows; the desktop main process still owns one for local-bound windows. When zero PRs are tracked yet, the forced full-snapshot `discoverLanePullRequests` fetch is throttled to a 10-minute cadence instead of running every tick — user-driven surfaces discover PRs on their own reads anyway | +| `prMergeAutoSettlementService.ts` | Applies the enabled lane-PR merge settlement policy after each polling snapshot. It files eligible, unblocked chat and tracked-agent-CLI sessions for a newly discovered merged PR, but emits `pr-sessions-auto-settled` only when the preceding in-memory snapshot contained that PR as open or draft. A first-sight merge — including backfilled history from another machine or the first snapshot after restart — is filed silently, so an imported history cannot generate merge toasts or push notifications. | | `prChatCards.ts` | Converts bounded PR polling transitions into durable `ade_card` episodes for linked Work chats: CI completion/failure, review received, merge ready, conflicts, and merged. CI jobs are failure-first, capped at three visible rows with `rowsTruncated`, and report an honest `degradedReason` + Retry action when both job/check detail sources fail instead of rendering an empty success state. Desktop-main and daemon-owned pollers call the same emitter, and failures are isolated per PR/session so one cold or malformed chat cannot stop the poll loop. | | `prSummaryService.ts` | AI PR summary generator; caches `PrAiSummary` per `(prId, headSha)` in `pull_request_ai_summaries` so pushes invalidate the cache | | `workflowGraph.ts` | `createWorkflowGraph` — reconstructs the CI pipeline DAG (`PrWorkflowGraph`) behind a swappable `WorkflowGraph` interface. GitHub's jobs API does not return `needs:`, so the graph is built by parsing the workflow YAML that actually ran and joining it to live run state. Parses **only** `jobs..needs` and `jobs..strategy.matrix`, with the existing `yaml` dep. Source order: lane worktree `git show :.github/workflows/` → GitHub Contents API `?ref=` (fork PRs / non-local repos) → `source: "none"` with an `unavailableReason`; it never guesses an edge. A single WORKFLOW degrades to flat swimlanes (not the whole graph) when a job uses a reusable workflow (`uses:`), has a `${{ }}` `name:`, or the YAML will not parse. Matrix legs collapse into one node whose state is the worst leg (failed > running > queued > passed > skipped); `tier` is a cycle-safe longest-path rank over `needs`; `criticalPath` is the longest-duration chain. Running nodes report live elapsed. Parsed YAML is cached per `(repo, headSha)` behind a TTL; the graph itself is always recomputed from live run state. | diff --git a/docs/features/search/README.md b/docs/features/search/README.md index fd9ae246a2..a6aef9885b 100644 --- a/docs/features/search/README.md +++ b/docs/features/search/README.md @@ -104,11 +104,14 @@ Desktop ⌘K command palette: universal-search seam: `useUniversalSearch` (debounced `window.ade.search` query), the `KindIcon` / highlight helpers, and the `SearchResultRow` entity rows grouped by kind (`ENTITY_KIND_ORDER` / `ENTITY_KIND_LABEL`). -- `apps/desktop/src/renderer/components/app/CommandPalette.tsx` — hosts the - flat-index interleaving of command matches and entity results, and - `activateResult`'s `kind → navigate` switch (chat/terminal/pr/lane/commit/ - branch/file/linear/artifact → the matching tab, relying on the deep-link - navigate listener to focus the target). +- `apps/desktop/src/renderer/components/app/CommandPalette.tsx` and + `commandPaletteThreads.tsx` — host the flat-index interleaving of command, + thread, and entity results, and `activateResult`'s `kind → navigate` switch + (chat/terminal/pr/lane/commit/branch/file/linear/artifact → the matching tab, + relying on the deep-link navigate listener to focus the target). Thread entries + always retain their owner machine name for matching; results show an amber + name marker only when that owner is not This Mac, including threads from the + remote-bound active tab. `ade search` CLI + agent skill: diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index e38ab92b7a..8f8d26d252 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -558,7 +558,11 @@ Cross-machine Work union: replaces it when the owning runtime returns the authoritative row. Foreign lane presentation applies the same active/snoozed/settled filing and quiet collapse rules as local lanes while keeping runtime-pinned actions directed - to the owner. + to the owner. Its machine marker follows the physical Mac, not the selected + tab binding: `isActiveBinding` decides where the lane renders, while + `isThisMachine` decides whether the amber elsewhere glyph appears. Thus a + remote-bound tab still labels every remotely owned lane, including those in + its primary list rather than the foreign union. - `apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx`, `SessionListPane.tsx`, and `apps/desktop/src/renderer/lib/terminalAttention.ts` — route chat-created diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index e4bb128504..e221ca2c86 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -562,12 +562,13 @@ Renderer surfaces: leaving the session list. The list is a **cross-machine union** (`useCrossMachineLaneUnion` from `renderer/state/crossMachineLanes.ts`): chats in flight on every connected machine appear regardless of which machine the - project tab is bound to. A lane that is not on This Mac carries a monochrome - `Desktop` marker on its header (`LaneMachineMarker.tsx`) — the lane accent owns - the color channel — that promotes from a bare glyph to the machine's name when a - glyph alone would be ambiguous (the machine is offline, two or more foreign - machines are on screen, or the branch also - exists elsewhere). Foreign lanes are listed only when they + project tab is bound to. A lane not on the **physical Mac** running ADE carries + one amber `DesktopTower` marker — always a glyph in the sidebar, with the + machine name on hover. The tab's binding only decides where a row renders; it + never changes whether the work is marked as elsewhere. This makes an unmarked + row mean "here" even in a remote-bound tab. A one-session foreign lane has no + divider, so its card carries the same glyph; cards beneath a real lane header + suppress the repeated marker. Foreign lanes are listed only when they have sessions, after the same search and lane filter the local list applies, so the union stays "work in flight" rather than an inventory of every lane everywhere; the empty state accounts for them, so "No sessions" cannot claim an @@ -612,12 +613,12 @@ Renderer surfaces: that serves lane badges, and a filtered empty state identifies and clears the active chips. - `apps/desktop/src/renderer/components/terminals/LaneMachineMarker.tsx` — the - adaptive machine marker on a foreign lane header, rendered only for lanes that - are not on the machine you are sitting at, so the common single-machine case - pays nothing. Monochrome by design: a tint here would read as a second lane - color. It has a dimmed form with its own tooltip and `, offline` - accessible name, and on an unreachable machine's row it is the only thing that - says why the group has gone quiet. + amber tower marker on a lane header, rendered only for lanes that are not on + the physical Mac you are sitting at, so the common single-machine case pays + nothing. It is always a glyph in the sidebar; the tooltip supplies the machine + name. It has a dimmed form with its own `, offline` accessible name, + and on an unreachable machine's row it is the only thing that says why the + group has gone quiet. - `apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx` — the right-click menu for a lane owned by another machine. Its `online` prop is read live from the store rather than captured at right-click time, so a machine @@ -632,6 +633,9 @@ Renderer surfaces: the same id, the launch is deleted, or the two-minute optimistic window expires. This reconciliation prevents both a blank launch interval and a duplicate raw-id lane under the active machine. + Its marker resolver separately distinguishes `isActiveBinding` (where a lane + renders) from `isThisMachine` (whether it is marked): a remote-bound tab still + marks all lanes that are elsewhere, even when it has no foreign union rows. It also owns presence: `applyReachability` decides, from the connection snapshot alone, which machines are live, which are dimmed, and which are forgotten. A drop is believed only once a reconnect attempt has completed and @@ -673,14 +677,15 @@ Renderer surfaces: landing after a project-tab switch cannot suppress the new scope's first lane read. - `apps/desktop/src/renderer/components/terminals/SessionCard.tsx` — - full-bleed three-line Work row. Line one adapts machine, pin, singleton lane, - spawn lineage, drifted branch, diff, and last-activity identity around + full-bleed three-line Work row. Line one adapts pin, singleton lane, spawn + lineage, drifted branch, diff, and last-activity identity around `SessionStatusSlot`; line two is the elastic title with a singleton lane's fixed-width PR badge at the right edge, directly beneath the status; line three keeps the sanitized preview, Claude TTL, failure exit code, and - provider mark. Grouped lane headers own repeated machine/PR identity, while a - lane with exactly one session has no redundant header and promotes the lane - identity and PR navigation onto the card. + provider mark. A foreign singleton adds the fixed-width amber machine glyph to + the line-one status cluster; grouped lane headers own repeated machine/PR + identity. A lane with exactly one session has no redundant header and promotes + the lane identity and PR navigation onto the card. After one second `SessionHoverCard` carries the lower-frequency metadata removed from the row (including clickable PR and parent-thread facts). The Lane labels on singleton rows and hover details show the shared animated diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 9d3ef1c0b0..2b473096a8 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -123,8 +123,12 @@ Lane groups with two or more sessions render an accent-coloured lane name, optional machine/PR markers, then indent the cards beside a lane-tinted rail. A lane with exactly one session suppresses the redundant divider and rail; the full-width card carries lane identity and PR state instead, and its context -menu inherits the lane actions. The singleton/header transition participates -in the same layout animation as lane reordering. +menu inherits the lane actions. For a foreign singleton, that card also carries +the same amber machine glyph the omitted header would have shown. The marker is +present exactly when the lane is not on the physical Mac, not when it differs +from the project tab's binding; a grouped header owns the glyph so its child +cards do not repeat it. The singleton/header transition participates in the +same layout animation as lane reordering. Filing comes from `sessionFilingBucket`, which combines canonical lifecycle with the snooze visibility overlay in one shared answer. Snooze still yields to @@ -200,7 +204,9 @@ every read and mutation pinned to that machine. An offline machine's lane group is dimmed and folds shut like a quiet one; expanding it is allowed, but every card in it is disabled and reads " is offline". The other disabled foreign row is one whose reachable machine has not resolved a project binding -yet. +yet. A one-session reachable foreign lane is allowed to use the same headerless +shape as a local singleton; an offline machine remains grouped so its folded, +dimmed state has a header to live on. In-flight chat handoffs are rendered as temporary placeholder cards in the same sidebar. `TerminalsPage` pulls matching `HandoffLaunchJob` @@ -246,14 +252,15 @@ a parent even when search, lane filtering, or a collapsed group hides its row. The full card is one full-bleed row with three lines: 1. **Where + status** — an adaptive identity slot on the left and - `SessionStatusSlot` on the right. The identity slot can carry the owning - machine, a pin, the lane identity for a singleton lane, spawned-chat - lineage, a branch only when it differs from the lane's declared branch, and - the lane PR for a singleton. When none of those apply, session delta or - last-activity time is the floor, so the line never renders empty. A grouped - lane owns machine identity and PR state in its header; child rows do not - repeat them. Lane identity always uses the lane accent and `LaneIcon`; - branch identity is always muted and uses `BranchIcon`. + `SessionStatusSlot` on the right. The identity slot can carry a pin, the lane + identity for a singleton lane, spawned-chat lineage, a branch only when it + differs from the lane's declared branch, and the lane PR for a singleton. + A foreign singleton gets one fixed-width amber machine glyph in the right + status cluster; the card's hover details name the machine. When none of those + apply, session delta or last-activity time is the floor, so the line never + renders empty. A grouped lane owns machine identity and PR state in its + header; child rows do not repeat them. Lane identity always uses the lane + accent and `LaneIcon`; branch identity is always muted and uses `BranchIcon`. 2. **Title + singleton PR** — `primarySessionLabel()` is the prominent, elastic element. When the card stands in for a one-session lane, the shared `LanePrBadge` sits at the right edge directly beneath the lifecycle status,