From d2b294479c85e9f4fe704e109846b51757c77ed4 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:08:36 -0400 Subject: [PATCH 1/4] fix(work): dim machines that drop instead of vanishing them A machine used to leave the Work sidebar entirely on any connection blip. The reconnect grace window was 6s while a single connect candidate is allowed 10s and candidates are dialed in sequence, so every wifi hiccup and every sleep/wake yanked a machine's whole lane and chat group out of the list and animated it back a moment later. Users read that as "my machines disappear". Presence is now three verdicts instead of two, decided in one place: - LIVE: connected and still hosting this repository. - DIMMED: its lanes and chats stay on screen, collapsed and inert, with the offline form of the machine marker naming it and every card reading " is offline". A drop earns this only once a reconnect attempt has run to completion and failed - `connecting` observed while dropped, then a non-connected state - plus a 45s floor, with a 120s ceiling for a dial that never finishes and an immediate verdict for an `idle` target that will not redial at all. `lastAttemptedAt` cannot answer this on its own: a failed RPC over an established connection stamps it too, and that is the event most drops start with. - FORGOTTEN: the only case that deletes rows. A target gone from the connection snapshot, a connected machine that positively reports the repository missing (the #941 fix, preserved), or 24 hours unreachable. A machine we ARE connected to but cannot re-prove the repository on keeps its last verdict. Absence of proof is not proof of absence: a project list that has not caught up after a reconnect must not read as "the repo is gone". The foreign-lane context menu's machine-bound actions are now disabled from live store state rather than a flag captured at right-click time, which was a lie in the one case it looked like it covered. The same file also owned an undisclosed poller. Every ~5.4s, per connected foreign machine, it fired `lane.list` with `includeStatus` (a git status and a worktree probe per lane, plus a state-snapshot row written per lane, on the other machine) alongside `session.list` - and only the Work tab being selected gated it, not whether the window was visible at all. It now stops entirely while the window is hidden and refreshes once on the way back; chats are re-read every 10s and lanes on their own 30s cadence, with an immediate lane read when a chat names a lane that machine has never reported. For one connected foreign machine that is 22.2 to 7.7 calls/min visible, and to zero hidden. Also fixes a latent wedge found while testing: a refresh outlives its own runtime, and bookkeeping from a torn-down run left `refreshInFlight` set for whoever mounted next, which then scheduled nothing at all. Co-Authored-By: Claude Fable 5 --- .../terminals/ForeignLaneContextMenu.tsx | 11 +- .../terminals/SessionListPane.test.tsx | 41 +- .../components/terminals/SessionListPane.tsx | 63 ++- .../terminals/useWorkLaneContextMenu.test.tsx | 68 ++- .../terminals/useWorkLaneContextMenu.tsx | 33 +- apps/desktop/src/renderer/state/appStore.ts | 32 +- .../renderer/state/crossMachineLanes.test.ts | 399 ++++++++++++---- .../src/renderer/state/crossMachineLanes.ts | 450 +++++++++++++----- docs/ARCHITECTURE.md | 2 +- docs/features/remote-runtime/README.md | 32 +- .../features/terminals-and-sessions/README.md | 44 +- .../terminals-and-sessions/ui-surfaces.md | 13 +- 12 files changed, 901 insertions(+), 287 deletions(-) diff --git a/apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx index 7a2be15fc..3914f46a8 100644 --- a/apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx @@ -13,6 +13,8 @@ import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; type ForeignLaneContextMenuProps = { lane: LaneSummary; machineName: string; + /** Live, not captured: every action below runs on the owning machine. */ + online: boolean; x: number; y: number; onClose: () => void; @@ -28,6 +30,7 @@ function branchNameFromRef(ref: string | null | undefined): string { export function ForeignLaneContextMenu({ lane, machineName, + online, x, y, onClose, @@ -98,17 +101,17 @@ export function ForeignLaneContextMenu({ {lane.name}
- {machineName} + {machineName}{online ? "" : " · offline"}
{separator} - + Start chat in lane - + Manage lane - + Open in Lanes {lane.worktreePath ? ( diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index e144056a7..761a01b2f 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -1207,18 +1207,43 @@ describe("SessionListPane", () => { expect((await screen.findByRole("tooltip")).textContent).toContain("Mac Studio (12)"); }); - it("removes an offline machine's lane, chats, and marker from the list", () => { + it("dims an offline machine's lane and folds its chats away instead of removing them", () => { seedForeignMachine({ online: false }); renderPane(); - // Nothing about an unreachable machine survives in Work: not the lane - // group, not its chats, not a dimmed placeholder naming the machine. - expect(screen.queryByText("Elsewhere Lane")).toBeNull(); - expect(screen.queryByText("Chat on the other machine")).toBeNull(); + // The work did not stop existing because the machine went to sleep, so the + // group stays — named, dimmed, and collapsed rather than presented as live. + const header = screen.getByText("Elsewhere Lane").closest( + ".ade-lane-group-header", + )!; + expect(header.closest(".opacity-55")).not.toBeNull(); + const marker = document.querySelector("[data-machine-marker-mode]")!; + expect(marker.getAttribute("data-machine-online")).toBe("false"); + // A glyph cannot say "offline", so the machine name is always spelled out. + expect(marker.getAttribute("data-machine-marker-mode")).toBe("name"); + expect(screen.getAllByText("Mac Studio (12)").length).toBeGreaterThan(0); expect(document.querySelector('[data-session-id="session-elsewhere"]')).toBeNull(); - expect(document.querySelector("[data-machine-marker-mode]")).toBeNull(); - expect(screen.queryByText("Mac Studio (12)")).toBeNull(); - expect(screen.queryByText(/is offline/i)).toBeNull(); + }); + + it("keeps an expanded offline group open, and its chats inert", async () => { + seedForeignMachine({ online: false }); + const toggleWorkSectionCollapsed = vi.fn(); + renderPane({ + workCollapsedSectionIds: ["lane-open:target-studio:lane-elsewhere"], + toggleWorkSectionCollapsed, + }); + + // Reading a dropped machine's last-known work is allowed; acting on it is + // not, and the card says which machine is gone rather than failing later. + const card = document.querySelector('[data-session-id="session-elsewhere"]')!; + expect(card).toBeTruthy(); + expect(screen.getByText("Mac Studio (12) is offline")).toBeTruthy(); + expect(card.querySelector("button")?.hasAttribute("disabled")).toBe(true); + // Its chats still LOOK active — that is just the last thing the machine + // reported — so nothing may treat that as a reason to slam the group shut. + await waitFor(() => { + expect(toggleWorkSectionCollapsed).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 59ce95d21..378ce3f76 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -306,6 +306,7 @@ function StickyGroupHeader({ variant = "default", busyLabel = null, heading = false, + dimmed = false, quietCounts = null, pinned = false, dragProps = null, @@ -336,6 +337,8 @@ function StickyGroupHeader({ * on this machine, so local-only setups never render one. */ machineMarker?: React.ReactNode; + /** Dims the whole group — used for lanes on a machine that has gone offline. */ + dimmed?: boolean; /** Compact action shown next to the count for non-lane headers. */ headerAction?: React.ReactNode; /** `lane` uses a larger header and pads the nested session list. */ @@ -393,7 +396,7 @@ function StickyGroupHeader({ transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }} onLayoutAnimationStart={() => setSliding(true)} onLayoutAnimationComplete={() => setSliding(false)} - className={cn("relative", !isLane && "mt-0.5 first:mt-0")} + className={cn("relative", !isLane && "mt-0.5 first:mt-0", dimmed && "opacity-55")} > {dropIndicatorEdge ? (
void } * Rendered ONLY for lanes that are not on the machine you're sitting at — the * common single-machine case pays nothing. Default form is a bare monochrome * glyph; the name is promoted into the row when a glyph alone would be - * ambiguous (two or more foreign machines on screen, or the branch also exists - * elsewhere). The lane accent owns the color channel, so this stays monochrome: - * a tint here would read as a second lane color. + * ambiguous (the machine is offline, two or more foreign machines on screen, or + * the branch also exists elsewhere). The lane accent owns the color channel, so + * this stays monochrome: a tint here would read as a second lane color. * - * Every marked machine is reachable — offline machines leave the sidebar — so - * there is no dimmed variant here. + * An unreachable machine keeps its rows, so this has a dimmed form — and it is + * the only thing on the row that says why the group has gone quiet. */ function LaneMachineMarker({ marker }: { marker: CrossMachineLaneMarker }) { return ( @@ -634,18 +637,30 @@ function LaneMachineMarker({ marker }: { marker: CrossMachineLaneMarker }) { forceEnabled content={{ label: marker.machineName, - description: "This lane lives on another connected machine.", + description: marker.online + ? "This lane lives on another connected machine." + : "This machine is offline. Its lanes and chats are shown as last reported and cannot be acted on.", }} > - + {marker.mode === "name" ? {marker.machineName} : null} @@ -1264,13 +1279,14 @@ export const SessionListPane = React.memo(function SessionListPane({ const isFirst = !sessionItemAnchorEmitted; if (isFirst) sessionItemAnchorEmitted = true; const foreignRow = options?.foreignRow; - // Offline machines never reach this point — their rows are filtered out of - // the union — so the only foreign block left is a reachable machine whose - // call-routing binding hasn't resolved yet. + // A card on an unreachable machine is shown as last reported and every + // action on it would fail, so it is inert and says which machine is gone. const disabledReason = foreignRow - ? !foreignRow.binding - ? `${foreignRow.machineName} is unavailable` - : null + ? !foreignRow.online + ? `${foreignRow.machineName} is offline` + : !foreignRow.binding + ? `${foreignRow.machineName} is unavailable` + : null : deleteProgressByLaneId[session.laneId] ? `${getLaneDeleteStatusLabel(deleteProgressByLaneId[session.laneId])} lane` : null; @@ -1474,7 +1490,13 @@ export const SessionListPane = React.memo(function SessionListPane({ const foreignRow = foreignRows.find( (row) => `${row.machineId}:${row.lane.id}` === laneId, ); - if (foreignRow && partitionQuietSessions(foreignRow.sessions).active.length > 0) { + // An offline machine's chats can look "active" — that is just the last + // thing it reported. Discarding the expansion on that evidence would slam + // the group shut the moment the user opened it to read them. + if ( + foreignRow?.online + && partitionQuietSessions(foreignRow.sessions).active.length > 0 + ) { toggleWorkSectionCollapsed(marker, { preserveDeeplink: true }); } } @@ -1757,7 +1779,10 @@ export const SessionListPane = React.memo(function SessionListPane({ const compositeLaneId = `${row.machineId}:${row.lane.id}`; const marker = markersByLaneId.get(compositeLaneId) ?? null; const quiet = partitionQuietSessions(row.sessions); - const laneQuiet = quiet.active.length === 0; + // An offline machine's chats cannot be running, whatever they last + // reported, so the group folds shut like a quiet one: retained and + // inspectable, not presented as live work. + const laneQuiet = !row.online || quiet.active.length === 0; const laneOpenMarker = `lane-open:${compositeLaneId}`; const collapsed = laneQuiet ? !workCollapsedSectionIds.includes(laneOpenMarker) @@ -1784,6 +1809,7 @@ export const SessionListPane = React.memo(function SessionListPane({ settled: quiet.settled.length, } : null} + dimmed={!row.online} onToggleCollapsed={() => { if (laneQuiet) toggleWorkSectionCollapsed(laneOpenMarker); else toggleWorkLaneCollapsed(compositeLaneId); @@ -1793,6 +1819,7 @@ export const SessionListPane = React.memo(function SessionListPane({ row.lane, row.binding!, row.machineName, + row.machineId, event, ) : undefined} diff --git a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx index 6de0630f5..7c1b70737 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx @@ -13,6 +13,9 @@ const setWorkViewState = vi.fn(); const switchRemoteProject = vi.fn().mockResolvedValue(undefined); const switchProjectToPath = vi.fn().mockResolvedValue(undefined); +/** Machine liveness the foreign menu reads live from the root store. */ +let studioOnline = true; + let capturedLaneContextMenuProps: Record | null = null; let capturedManageLaneHostProps: Record | null = null; @@ -61,6 +64,18 @@ vi.mock("../../state/appStore", async () => { switchRemoteProject, switchProjectToPath, }), + useRootAppStore: (selector: (state: Record) => unknown) => + selector({ + crossMachineLanesByMachineId: { + studio: { + machineId: "studio", + machineName: "Studio", + online: studioOnline, + lanes: [], + sessions: [], + }, + }, + }), }; }); @@ -90,6 +105,7 @@ vi.mock("./WorkManageLaneDialogHost", () => ({ afterEach(() => { cleanup(); + studioOnline = true; capturedLaneContextMenuProps = null; capturedManageLaneHostProps = null; navigate.mockReset(); @@ -164,7 +180,7 @@ describe("useWorkLaneContextMenu", () => { }); act(() => { - result.current.triggerForeign(lane, binding, "Studio", { + result.current.triggerForeign(lane, binding, "Studio", "studio", { preventDefault: vi.fn(), clientX: 12, clientY: 34, @@ -213,7 +229,7 @@ describe("useWorkLaneContextMenu", () => { }); act(() => { - result.current.triggerForeign(lane, binding, "Studio", { + result.current.triggerForeign(lane, binding, "Studio", "studio", { preventDefault: vi.fn(), clientX: 12, clientY: 34, @@ -234,6 +250,54 @@ describe("useWorkLaneContextMenu", () => { expect(navigate).not.toHaveBeenCalled(); }); + it("disables every action on a machine that is offline", () => { + studioOnline = false; + Object.defineProperty(window, "ade", { + configurable: true, + value: { app: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } }, + }); + const lane = { + id: "lane-studio", + name: "Studio Lane", + laneType: "worktree", + branchRef: "refs/heads/studio-lane", + worktreePath: "/Users/studio/ADE/.ade/worktrees/studio-lane", + } as LaneSummary; + const binding = { + kind: "remote" as const, + key: "remote:studio:ade", + targetId: "studio", + projectId: "ade", + rootPath: "/Users/studio/ADE", + displayName: "ADE", + runtimeName: "Studio", + hostname: "studio.local", + }; + const { result } = renderHook(() => useWorkLaneContextMenu(), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.triggerForeign(lane, binding, "Studio", "studio", { + preventDefault: vi.fn(), + clientX: 12, + clientY: 34, + }); + }); + render(<>{result.current.menu}); + + // Every one of these runs on the machine that is gone, so offering them + // would only produce a failure the user cannot do anything about. + for (const name of ["Start chat in lane", "Manage lane", "Open in Lanes"]) { + const item = screen.getByRole("menuitem", { name }) as HTMLButtonElement; + expect(item.disabled).toBe(true); + fireEvent.click(item); + } + expect(setWorkViewState).not.toHaveBeenCalled(); + expect(capturedManageLaneHostProps?.laneId ?? null).toBeNull(); + expect(switchRemoteProject).not.toHaveBeenCalled(); + }); + it("opens lane management in Work without navigating to the Lanes tab", () => { const { result } = renderHook(() => useWorkLaneContextMenu(), { wrapper: ({ children }) => {children}, diff --git a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx index a009cc49b..fef8cfbb0 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom"; import type { LaneSummary, OpenProjectBinding } from "../../../shared/types"; import { useAppStore, + useRootAppStore, selectActiveProjectStateKey, } from "../../state/appStore"; import { useStartChatInLane } from "../../hooks/useStartChatInLane"; @@ -13,16 +14,17 @@ import { WorkManageLaneDialogHost } from "./WorkManageLaneDialogHost"; type MenuState = { laneId: string; x: number; y: number }; /** - * Foreign menus are only ever opened from rows the Work union has already - * narrowed to reachable machines, so there is no offline state to carry here. - * A captured `online` flag would have been a lie anyway: it is a snapshot from - * right-click time, so it still reads "online" in the one case it looked like - * it covered — the machine dropping while the menu is open. + * Foreign menus can be opened on a machine that is offline, so the menu has to + * know. It carries the machine ID rather than a captured `online` flag: a flag + * read at right-click time would still say "online" in the one case that + * matters, the machine dropping while the menu is open. Liveness is read from + * the store on every render instead. */ type ForeignMenuState = { lane: LaneSummary; binding: OpenProjectBinding; machineName: string; + machineId: string; x: number; y: number; }; @@ -40,6 +42,7 @@ export type ForeignLaneContextTrigger = ( lane: LaneSummary, binding: OpenProjectBinding, machineName: string, + machineId: string, e: { preventDefault: () => void; clientX: number; clientY: number }, ) => void; @@ -63,6 +66,13 @@ export function useWorkLaneContextMenu(options?: { const [menuState, setMenuState] = useState(null); const [foreignMenuState, setForeignMenuState] = useState(null); const [managedLane, setManagedLane] = useState(null); + // Read live, not captured: a machine that drops while its menu is open must + // disable the actions that would now fail rather than keep offering them. + const foreignMachineOnline = useRootAppStore((state) => + foreignMenuState + ? state.crossMachineLanesByMachineId[foreignMenuState.machineId]?.online ?? false + : false, + ); const lanesById = useMemo(() => { const map = new Map(); for (const lane of lanes) map.set(lane.id, lane); @@ -82,6 +92,7 @@ export function useWorkLaneContextMenu(options?: { lane, binding, machineName, + machineId, event, ) => { event.preventDefault(); @@ -90,6 +101,7 @@ export function useWorkLaneContextMenu(options?: { lane, binding, machineName, + machineId, x: event.clientX, y: event.clientY, }); @@ -125,7 +137,7 @@ export function useWorkLaneContextMenu(options?: { navigate, }); const startForeignChat = useCallback(() => { - if (!foreignMenuState || !projectStateKey) return; + if (!foreignMenuState || !projectStateKey || !foreignMachineOnline) return; const laneId = foreignMenuState.lane.id; setWorkViewState(projectStateKey, (previous) => ({ ...previous, @@ -141,13 +153,14 @@ export function useWorkLaneContextMenu(options?: { void navigate("/work"); }, [ close, + foreignMachineOnline, foreignMenuState, navigate, projectStateKey, setWorkViewState, ]); const openForeignLane = useCallback(() => { - if (!foreignMenuState) return; + if (!foreignMenuState || !foreignMachineOnline) return; const { binding, lane } = foreignMenuState; close(); const switching = binding.kind === "remote" @@ -159,6 +172,7 @@ export function useWorkLaneContextMenu(options?: { }); }, [ close, + foreignMachineOnline, foreignMenuState, navigate, selectLane, @@ -166,7 +180,7 @@ export function useWorkLaneContextMenu(options?: { switchRemoteProject, ]); const manageForeignLane = useCallback(() => { - if (!foreignMenuState) return; + if (!foreignMenuState || !foreignMachineOnline) return; const { binding, lane } = foreignMenuState; close(); setManagedLane({ @@ -174,7 +188,7 @@ export function useWorkLaneContextMenu(options?: { lane, binding, }); - }, [close, foreignMenuState]); + }, [close, foreignMachineOnline, foreignMenuState]); const menu = menuState || foreignMenuState || managedLane ? createPortal( @@ -221,6 +235,7 @@ export function useWorkLaneContextMenu(options?: { void; + /** + * Forgets machines outright — the one operation that does delete rows. Used + * only for a machine that has left the connection registry or has been + * unreachable long enough that its last-known work is no longer worth showing. + */ + dropCrossMachineLanes: (machineIds: readonly string[]) => void; setLaneInspectorTab: (laneId: string, tab: LaneInspectorTab) => void; clearLaneInspectorTab: (laneId: string) => void; focusSession: (sessionId: string | null) => void; @@ -1778,13 +1783,28 @@ const createAppState: StateCreator = (set, get) => { continue; } // Flag, never drop. The lanes and sessions carry over verbatim; the - // union hook decides what that flag hides. + // union hook decides how that flag renders. nextRecord[machineId] = { ...entry, online: isOnline }; changed = true; } return changed ? { crossMachineLanesByMachineId: nextRecord } : {}; }), + dropCrossMachineLanes: (machineIds) => + set((prev) => { + const dropped = new Set(machineIds); + const nextRecord: Record = {}; + let changed = false; + for (const [machineId, entry] of Object.entries(prev.crossMachineLanesByMachineId)) { + if (dropped.has(machineId)) { + changed = true; + continue; + } + nextRecord[machineId] = entry; + } + return changed ? { crossMachineLanesByMachineId: nextRecord } : {}; + }), + setLaneInspectorTab: (laneId, tab) => set((prev) => ({ laneInspectorTabs: { diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts index 1427078da..c98d1c0bc 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts @@ -11,8 +11,8 @@ import { decodeForeignLanes, decodeForeignSessions, reconcileCrossMachineOptimisticSessions, + orderCrossMachineRows, resolveCrossMachineLaneMarkers, - selectReachableCrossMachineRows, resolveThisMachineBindingForOrigin, resetCrossMachineLaneSyncForTest, seedCrossMachineOptimisticChatSession, @@ -72,7 +72,17 @@ function makeSession(overrides: Partial = {}): TerminalS } as TerminalSessionSummary; } +/** jsdom reports `visible` and has no API to change it. */ +function setDocumentVisibility(state: "visible" | "hidden"): void { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + beforeEach(() => { + setDocumentVisibility("visible"); useAppStore.setState({ lanes: [], projectBinding: null, @@ -92,8 +102,8 @@ afterEach(() => { } }); -describe("offline machines leave the sidebar but stay in the store", () => { - it("retains a dropped machine's slice while hiding it from the union", () => { +describe("offline machines stay in the sidebar, dimmed", () => { + it("keeps a dropped machine's lanes and chats, flagged offline", () => { const store = useAppStore.getState(); store.mergeCrossMachineLanes({ machineId: "target-studio", @@ -117,13 +127,59 @@ describe("offline machines leave the sidebar but stay in the store", () => { localLanes: [], machines: useAppStore.getState().crossMachineLanesByMachineId, }); - // Retained for the push-divergence guard, which needs the last-known branch - // state of a machine that is currently unreachable... + // The machine is unreachable, not gone: the sidebar still renders the row, + // dimmed, and the push-divergence guard still has its last-known branch state. expect(rows).toHaveLength(1); expect(rows[0].online).toBe(false); expect(rows[0].sessions).toHaveLength(1); - // ...but nothing the Work sidebar renders sees it. - expect(selectReachableCrossMachineRows(rows)).toEqual([]); + expect(orderCrossMachineRows(rows)).toHaveLength(1); + }); + + it("sinks offline rows below reachable ones", () => { + const rows = buildCrossMachineLaneRows({ + localLanes: [], + machines: { + "target-studio": { + machineId: "target-studio", + machineName: "Mac Studio (12)", + targetId: "target-studio", + projectId: "project-a", + online: false, + // Newer activity than the reachable machine, and still ranked below it. + lanes: [makeLane({ id: "lane-offline", createdAt: "2026-07-28T10:00:00.000Z" })], + sessions: [], + lastSyncedAtMs: Date.now(), + error: null, + }, + "target-laptop": { + machineId: "target-laptop", + machineName: "MacBook Pro (97)", + targetId: "target-laptop", + projectId: "project-a", + online: true, + lanes: [makeLane({ id: "lane-online", createdAt: "2026-07-20T10:00:00.000Z" })], + sessions: [], + lastSyncedAtMs: Date.now(), + error: null, + }, + }, + }); + expect(orderCrossMachineRows(rows).map((row) => row.lane.id)) + .toEqual(["lane-online", "lane-offline"]); + }); + + it("forgets a machine outright only when asked to", () => { + useAppStore.getState().mergeCrossMachineLanes({ + machineId: "target-studio", + machineName: "Mac Studio (12)", + online: true, + lanes: [makeLane({ id: "lane-foreign" })], + }); + useAppStore.getState().dropCrossMachineLanes(["target-studio"]); + expect(useAppStore.getState().crossMachineLanesByMachineId["target-studio"]).toBeUndefined(); + const empty = useAppStore.getState().crossMachineLanesByMachineId; + useAppStore.getState().dropCrossMachineLanes(["target-studio"]); + expect(useAppStore.getState().crossMachineLanesByMachineId).toBe(empty); }); it("keeps the last known lanes when a read fails", () => { @@ -336,7 +392,7 @@ describe("adaptive machine marker", () => { expect(resolveCrossMachineLaneMarkers(rows).has("lane-active")).toBe(true); }); - it("marks nothing for an offline machine, and does not let it name a reachable one", () => { + it("marks an offline machine by name, and still counts its branch as elsewhere", () => { const rows = buildCrossMachineLaneRows({ localLanes: [], machines: { @@ -365,13 +421,18 @@ describe("adaptive machine marker", () => { }, }); - const markers = resolveCrossMachineLaneMarkers(selectReachableCrossMachineRows(rows)); - expect(markers.has("target-studio:lane-offline")).toBe(false); - // The offline machine holds the same branch, but it is invisible — so it must - // not promote the reachable lane to the "also elsewhere" name form. + const markers = resolveCrossMachineLaneMarkers(rows); + // A glyph alone cannot say "offline", so the name is always promoted there. + expect(markers.get("target-studio:lane-offline")).toMatchObject({ + online: false, + mode: "name", + }); + // 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({ - mode: "glyph", - sameBranchElsewhere: false, + online: true, + mode: "name", + sameBranchElsewhere: true, }); }); @@ -825,10 +886,10 @@ describe("cross-machine refresh scheduling", () => { await vi.advanceTimersByTimeAsync(400); expect(callAction).toHaveBeenCalledTimes(2); - // The old setInterval path started another generation after five seconds, - // invalidating this still-live eight-second read. A settled-chain poll must - // leave it alone no matter how much wall time passes while it is in flight. - await vi.advanceTimersByTimeAsync(5_500); + // The old setInterval path started another generation on its own cadence, + // invalidating this still-live read. A settled-chain poll must leave it alone + // for as long as the read's own timeout allows it to run. + await vi.advanceTimersByTimeAsync(7_500); expect(callAction).toHaveBeenCalledTimes(2); pending.splice(0).forEach((resolve, index) => resolve({ @@ -836,16 +897,128 @@ describe("cross-machine refresh scheduling", () => { })); await Promise.resolve(); await Promise.resolve(); - await vi.advanceTimersByTimeAsync(4_999); + await vi.advanceTimersByTimeAsync(9_000); expect(callAction).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(401); - expect(callAction).toHaveBeenCalledTimes(4); + // The next tick reads chats only: the lane list has its own 30s cadence, and + // no chat referenced a lane this machine has not already reported. + await vi.advanceTimersByTimeAsync(2_000); + expect(callAction).toHaveBeenCalledTimes(3); + expect(callAction).toHaveBeenLastCalledWith( + "target-studio", + "project-a", + expect.objectContaining({ domain: "session", action: "list" }), + ); + + stop(); + }); + + it("re-reads lanes on their own slow cadence, and immediately for an unseen lane", async () => { + vi.useFakeTimers(); + const requests: Array<{ domain: string; action: string }> = []; + let sessionLaneId = "lane-known"; + const callAction = vi.fn(async ( + _targetId: string, + _projectId: string, + request: { domain: string; action: string }, + ) => { + requests.push({ domain: request.domain, action: request.action }); + return request.domain === "lane" + ? { result: { lanes: [{ id: "lane-known", name: "Known", branchRef: "feature/known" }] } } + : { result: { sessions: [{ id: "session-1", laneId: sessionLaneId }] } }; + }); + window.ade = { + remoteRuntime: { + callAction, + getConnectionSnapshot: vi.fn(async () => ({ + connections: [{ + state: "connected", + target: { id: "target-studio", name: "Mac Studio (12)", hostname: "studio" }, + projects: [{ + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + gitOriginUrl: "git@github.com:acme/repo-a.git", + }], + }], + connectedCount: 1, + })), + onConnectionSnapshotChanged: vi.fn(() => () => {}), + }, + } as unknown as typeof window.ade; + + const stop = startCrossMachineLaneSync({ + scopeKey: "local:/repo-a", + repoDisplayName: "Repo A", + repoOriginUrl: "git@github.com:acme/repo-a.git", + boundTargetId: null, + boundProjectId: null, + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(400); + expect(requests.filter((entry) => entry.domain === "lane")).toHaveLength(1); + + // Two more ticks inside the lane window: chats only. + await vi.advanceTimersByTimeAsync(21_000); + expect(requests.filter((entry) => entry.domain === "lane")).toHaveLength(1); + expect(requests.filter((entry) => entry.domain === "session").length).toBeGreaterThan(1); + + // A chat on a lane we have never seen cannot be rendered without its lane + // row, so it forces the read the slow cadence would have deferred. + sessionLaneId = "lane-brand-new"; + await vi.advanceTimersByTimeAsync(10_500); + expect(requests.filter((entry) => entry.domain === "lane")).toHaveLength(2); + + stop(); + }); + + it("stops polling while the window is hidden and refreshes on the way back", async () => { + vi.useFakeTimers(); + const callAction = vi.fn(async () => ({ result: { sessions: [] } })); + window.ade = { + remoteRuntime: { + callAction, + getConnectionSnapshot: vi.fn(async () => ({ + connections: [{ + state: "connected", + target: { id: "target-studio", name: "Mac Studio (12)", hostname: "studio" }, + projects: [{ + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + gitOriginUrl: "git@github.com:acme/repo-a.git", + }], + }], + connectedCount: 1, + })), + onConnectionSnapshotChanged: vi.fn(() => () => {}), + }, + } as unknown as typeof window.ade; + + const stop = startCrossMachineLaneSync({ + scopeKey: "local:/repo-a", + repoDisplayName: "Repo A", + repoOriginUrl: "git@github.com:acme/repo-a.git", + boundTargetId: null, + boundProjectId: null, + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(400); + const whileVisible = callAction.mock.calls.length; + expect(whileVisible).toBeGreaterThan(0); + + setDocumentVisibility("hidden"); + await vi.advanceTimersByTimeAsync(60_000); + expect(callAction).toHaveBeenCalledTimes(whileVisible); + + setDocumentVisibility("visible"); + await vi.advanceTimersByTimeAsync(400); + expect(callAction.mock.calls.length).toBeGreaterThan(whileVisible); stop(); }); }); -describe("reconnect grace before a machine leaves the sidebar", () => { +describe("believing a drop before a machine dims", () => { const CONNECTED_TARGET = { id: "target-studio", name: "Mac Studio (12)", @@ -882,14 +1055,20 @@ describe("reconnect grace before a machine leaves the sidebar", () => { }; } + /** The target itself is gone — unpaired, or removed from the registry. */ + function emptySnapshot() { + return { connections: [], connectedCount: 0 }; + } + function startWithSnapshots(): { stop: () => void; - push: (state: string) => void; + push: (next: unknown) => void; + pushState: (state: string) => void; } { let emit: ((next: unknown) => void) | null = null; window.ade = { remoteRuntime: { - // Reads never resolve: this test is about reachability, not lane data. + // Reads never resolve: this suite is about reachability, not lane data. callAction: vi.fn(() => new Promise(() => {})), getConnectionSnapshot: vi.fn(async () => snapshot("connected")), onConnectionSnapshotChanged: vi.fn((listener: (next: unknown) => void) => { @@ -905,11 +1084,16 @@ describe("reconnect grace before a machine leaves the sidebar", () => { boundTargetId: null, boundProjectId: null, }); - return { stop, push: (state) => emit?.(snapshot(state)) }; + return { + stop, + push: (next) => emit?.(next), + pushState: (state) => emit?.(snapshot(state)), + }; } - const isOnline = () => - useAppStore.getState().crossMachineLanesByMachineId["target-studio"]?.online; + const entry = () => + useAppStore.getState().crossMachineLanesByMachineId["target-studio"]; + const isOnline = () => entry()?.online; // Seeded AFTER the sync starts: `startCrossMachineLaneSync` applies its repo // scope, which clears slices carried over from another scope. @@ -925,60 +1109,107 @@ describe("reconnect grace before a machine leaves the sidebar", () => { }); } - it("keeps a reconnecting machine visible, then hides it once the drop persists", async () => { - vi.useFakeTimers(); - const { stop, push } = startWithSnapshots(); + async function startSeeded() { + const handle = startWithSnapshots(); await Promise.resolve(); await vi.advanceTimersByTimeAsync(400); seedMachine(); expect(isOnline()).toBe(true); + return handle; + } + + it("dims only after a reconnect attempt has completed and failed", async () => { + vi.useFakeTimers(); + const { stop, pushState } = await startSeeded(); // `connect()` publishes `connecting` before every automatic redial, and one - // failed liveness ping publishes `error`. Neither may reflow the sidebar. - push("connecting"); + // failed liveness ping publishes `error`. Neither may dim the group on its own. + pushState("connecting"); expect(isOnline()).toBe(true); await vi.advanceTimersByTimeAsync(3_000); - push("error"); + pushState("error"); + // The dial has now failed, but a single failed dial inside the floor is still + // a blip: one connect candidate alone is allowed ten seconds. + expect(isOnline()).toBe(true); + await vi.advanceTimersByTimeAsync(20_000); expect(isOnline()).toBe(true); - // Six seconds after the FIRST drop, not after the latest snapshot. - await vi.advanceTimersByTimeAsync(3_100); + // 45s after the FIRST drop, not after the latest snapshot. + await vi.advanceTimersByTimeAsync(22_100); expect(isOnline()).toBe(false); + // Dimmed, not deleted: the lanes and chats are still there to render. + expect(entry().lanes).toHaveLength(1); + expect(entry().sessions).toHaveLength(1); stop(); }); - it("hides a machine that reconnects without a checkout of this repository", async () => { + it("holds a machine that never finishes a dial until the ceiling", async () => { vi.useFakeTimers(); - const listener = { emit: null as ((next: unknown) => void) | null }; - window.ade = { - remoteRuntime: { - callAction: vi.fn(() => new Promise(() => {})), - getConnectionSnapshot: vi.fn(async () => snapshot("connected")), - onConnectionSnapshotChanged: vi.fn((cb: (next: unknown) => void) => { - listener.emit = cb; - return () => { listener.emit = null; }; - }), - }, - } as unknown as typeof window.ade; - const stop = startCrossMachineLaneSync({ - scopeKey: "local:/repo-a", - repoDisplayName: "Repo A", - repoOriginUrl: "git@github.com:acme/repo-a.git", - boundTargetId: null, - boundProjectId: null, - }); - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(400); - seedMachine(); + const { stop, pushState } = await startSeeded(); + + // A dial wedged past its own timeout: `connecting` forever, no verdict. + pushState("connecting"); + await vi.advanceTimersByTimeAsync(60_000); expect(isOnline()).toBe(true); + await vi.advanceTimersByTimeAsync(61_000); + expect(isOnline()).toBe(false); + + stop(); + }); + + it("dims a manually disconnected machine at the floor, with no attempt to wait for", async () => { + vi.useFakeTimers(); + const { stop, pushState } = await startSeeded(); + + // `idle` means nothing is dialing and nothing will start on its own, so + // waiting for a failed attempt would wait forever. + pushState("idle"); + await vi.advanceTimersByTimeAsync(44_000); + expect(isOnline()).toBe(true); + await vi.advanceTimersByTimeAsync(1_500); + expect(isOnline()).toBe(false); + + stop(); + }); + + it("forgets a machine that has left the connection registry", async () => { + vi.useFakeTimers(); + const { stop, push } = await startSeeded(); + + push(emptySnapshot()); + // Nothing will ever refresh it again, so retaining rows would be a promise + // ADE cannot keep. + expect(entry()).toBeUndefined(); + + stop(); + }); + + it("forgets a machine that reconnects without a checkout of this repository", async () => { + vi.useFakeTimers(); + const { stop, push } = await startSeeded(); + + // Still "connected" — but the repo is provably gone from that machine, so it + // is no longer a read target. Left to raw connection state it would stay + // visible forever while never being refreshed again: permanently stale. + push(snapshotWithoutRepo()); + expect(entry()).toBeUndefined(); - // Still "connected" — but the repo is gone from that machine, so it is no - // longer a read target. Left to raw connection state it would stay online - // forever while never being refreshed again: permanently visible, stale. - listener.emit?.(snapshotWithoutRepo()); - await vi.advanceTimersByTimeAsync(6_100); + stop(); + }); + + it("forgets a machine that has been unreachable for a full day", async () => { + vi.useFakeTimers(); + const { stop, pushState } = await startSeeded(); + + pushState("connecting"); + pushState("error"); + await vi.advanceTimersByTimeAsync(46_000); expect(isOnline()).toBe(false); + expect(entry()).toBeDefined(); + + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + expect(entry()).toBeUndefined(); stop(); }); @@ -1010,14 +1241,15 @@ describe("reconnect grace before a machine leaves the sidebar", () => { await vi.advanceTimersByTimeAsync(400); seedMachine(); - // Machine drops while its lane/session read is still in flight, and stays - // down past the grace window. + // Machine drops while its lane/session read is still in flight, and its + // reconnect attempt fails. snapshots.emit?.(snapshot("connecting")); - await vi.advanceTimersByTimeAsync(6_100); + snapshots.emit?.(snapshot("error")); + await vi.advanceTimersByTimeAsync(46_000); expect(isOnline()).toBe(false); // The read finally lands. It must not flip the machine back on: nothing - // would hide it again until an unrelated snapshot happened to fire. + // would dim it again until an unrelated snapshot happened to fire. pendingReads.splice(0).forEach((resolve, index) => resolve({ result: index % 2 === 0 ? { lanes: [] } : { sessions: [] }, })); @@ -1057,12 +1289,11 @@ describe("reconnect grace before a machine leaves the sidebar", () => { seedMachine(); pending.release?.(); await vi.advanceTimersByTimeAsync(100); - // The snapshot survived, so its reachability verdict applies: the machine is - // held through the grace window and only then hidden. A discarded snapshot - // leaves `runtime.connections` empty and nothing ever flips it — the machine - // would stay visible forever, since `runRefresh` no longer sets reachability. + // The snapshot survived, so its verdict applies: the machine is held while + // the dial is outstanding and dims at the ceiling. A discarded snapshot + // leaves `runtime.connections` empty and nothing ever flips it. expect(isOnline()).toBe(true); - await vi.advanceTimersByTimeAsync(6_100); + await vi.advanceTimersByTimeAsync(121_000); expect(isOnline()).toBe(false); stop(); @@ -1107,32 +1338,30 @@ describe("reconnect grace before a machine leaves the sidebar", () => { await vi.advanceTimersByTimeAsync(100); expect(isOnline()).toBe(true); - await vi.advanceTimersByTimeAsync(6_100); + await vi.advanceTimersByTimeAsync(121_000); expect(isOnline()).toBe(false); second(); }); - it("restores a machine that reconnects inside the grace window without ever hiding it", async () => { + it("restores a machine that reconnects inside the window without ever dimming it", async () => { vi.useFakeTimers(); - const { stop, push } = startWithSnapshots(); - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(400); - seedMachine(); + const { stop, pushState } = await startSeeded(); - push("connecting"); - await vi.advanceTimersByTimeAsync(2_000); + pushState("connecting"); + await vi.advanceTimersByTimeAsync(20_000); expect(isOnline()).toBe(true); - push("connected"); - await vi.advanceTimersByTimeAsync(10_000); - // The grace timer from the blip must not fire against the healed machine. + pushState("connected"); + await vi.advanceTimersByTimeAsync(60_000); + // The deadline armed by the blip must not fire against the healed machine. expect(isOnline()).toBe(true); // A second drop gets a FULL window, not the remainder of the first one. - push("connecting"); - await vi.advanceTimersByTimeAsync(5_000); + pushState("connecting"); + pushState("error"); + await vi.advanceTimersByTimeAsync(44_000); expect(isOnline()).toBe(true); - await vi.advanceTimersByTimeAsync(1_100); + await vi.advanceTimersByTimeAsync(2_000); expect(isOnline()).toBe(false); stop(); diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.ts b/apps/desktop/src/renderer/state/crossMachineLanes.ts index 922351a01..50ad1fbf9 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.ts @@ -13,13 +13,15 @@ * - Machines are named absolutely (`THIS_MACHINE_NAME`, "MacBook Pro (97)"). * The word "remote" is never a machine name: once the tab's machine can * change, "remote" has no fixed referent. - * - A machine that drops leaves the sidebar entirely: its lanes, chats, and - * markers are hidden until it reconnects. Retained-but-dead rows read as a - * live list you can act on, and every action on them fails. + * - A machine that drops is dimmed, not deleted. Its lanes and chats stay on + * screen, collapsed and inert, marked with the machine that owns them. Rows + * leave the sidebar for two reasons only: the machine is gone from the + * registry, or it has been unreachable for a full day. * * Performance shape: active-binding refreshes are event-driven. Other machines - * do not have a renderer change feed, so one shared, ref-counted five-second - * fallback refresh keeps them current. Foreign reads are bounded, timed out, + * do not have a renderer change feed, so one shared, ref-counted fallback + * refresh keeps them current while the window is visible, and stops entirely + * while it is not. Foreign reads are bounded, timed out, * generation-cancellable, and never gate the local list. */ @@ -31,6 +33,7 @@ import type { OpenProjectBinding, RecentProjectSummary, RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectionState, RemoteRuntimeConnectionStatus, TerminalSessionSummary, } from "../../shared/types"; @@ -48,6 +51,7 @@ import { import { deriveLaneMachineOptions, type LaneMachineOption, + type LaneMachineRepoMatch, } from "../components/lanes/laneMachines"; import { rootAppStoreApi, @@ -64,15 +68,44 @@ const REFRESH_COALESCE_MS = 400; const MACHINE_READ_TIMEOUT_MS = 8_000; /** Upper bound on machines read at once, so ten paired machines can't fan out. */ const MAX_PARALLEL_MACHINE_READS = 4; -/** Fallback change feed for machines other than the active binding. */ -const FOREIGN_MACHINE_REFRESH_MS = 5_000; +/** + * Fallback change feed for machines other than the active binding, and only + * while the window is visible. Chats are what move between ticks; see + * `FOREIGN_LANE_REFRESH_MS` for why the lane read runs at its own, slower rate. + */ +const FOREIGN_MACHINE_REFRESH_MS = 10_000; +/** + * How often a foreign machine's lane list is re-read. `lane.list` with + * `includeStatus` resolves a git status and a worktree probe per lane and + * writes a state snapshot row per lane, on the OTHER machine, every time. Lane + * records themselves change on the scale of minutes, so paying that cost at the + * chat cadence bought nothing; a chat that appears on a lane we have never seen + * still forces an immediate lane read (`readMachine`). + */ +const FOREIGN_LANE_REFRESH_MS = 30_000; const OFFLINE_DIVERGENCE_MAX_AGE_MS = 60_000; /** - * How long a machine may be non-connected before Work hides it. Reconnects - * publish `connecting`/`error` states constantly, and hiding on the first one - * would make the sidebar reflow on every blip. + * Floor on how long a drop must persist before Work shows a machine as offline. + * `connect()` publishes `connecting` before every automatic redial and a single + * failed liveness ping publishes `error`, so believing the first non-connected + * snapshot would dim a machine on every websocket blip. One connect candidate + * alone is allowed ten seconds and candidates are tried in sequence, so this + * floor sits above a full dial cycle. + */ +const UNREACHABLE_FLOOR_MS = 45_000; +/** + * Backstop for a machine that never completes a reconnect attempt at all — a + * dial wedged past its own timeout, or a target whose autoconnect sweep never + * arrives. Without it, "wait for a failed attempt" would hold a dead machine + * bright forever. */ -const RECONNECT_GRACE_MS = 6_000; +const UNREACHABLE_CEILING_MS = 120_000; +/** + * How long an unreachable machine keeps its rows. A laptop that is shut for the + * night is still a machine you own with work on it; a machine you have not seen + * for a day is clutter. + */ +const OFFLINE_RETENTION_MS = 24 * 60 * 60 * 1000; /** Foreign session reads are a sidebar preview, not an archive. */ const FOREIGN_SESSION_LIMIT = 60; /** Match the local optimistic-session window while a foreign list catches up. */ @@ -127,13 +160,15 @@ export type CrossMachineLaneRow = { }; /** - * Everything the Work sidebar is allowed to see. Every field here is already - * narrowed to reachable machines — there is deliberately no unfiltered `rows` - * escape hatch, because a future consumer reaching for it would silently put - * offline machines back on screen. + * Everything the Work sidebar is allowed to see. + * + * Rows for a machine that has dropped are present and flagged `online: false`, + * not removed: the sidebar dims them and collapses their contents. Deciding + * what has actually left happens once, in `applyReachability`, at the store — + * so there is still no filter for a consumer here to get wrong. */ export type CrossMachineUnion = { - /** Rows on machines other than this one, most recent activity first. */ + /** Rows on machines other than this one, reachable first, then by activity. */ foreignRows: CrossMachineLaneRow[]; /** * Lane id → marker, present ONLY for lanes that are not on this machine. @@ -147,12 +182,9 @@ export type CrossMachineUnion = { * * 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: two or more distinct - * foreign machines are on screen at once, or this lane's branch also exists on - * another machine. - * - * Markers only ever describe reachable machines — an offline machine's rows are - * gone from the sidebar, so there is no "offline" form of this marker. + * 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. * * The lane accent already owns the color channel, so the marker is monochrome — * a tinted marker would read as a second, competing lane color. @@ -160,6 +192,8 @@ export type CrossMachineUnion = { export type CrossMachineLaneMarker = { machineId: string; machineName: string; + /** False while the owning machine is unreachable; the row reads as dimmed. */ + online: boolean; mode: "glyph" | "name"; /** Always the machine name — the glyph form still exposes it on hover. */ title: string; @@ -348,18 +382,18 @@ export function buildCrossMachineLaneRows(input: { } /** - * Narrows union rows to the machines Work is allowed to show. - * - * A machine that has dropped leaves the sidebar completely — its lanes, its - * chats, and the same-branch-elsewhere signal it would otherwise contribute to a - * lane that IS reachable. Rows for offline machines stay in the store because - * the push-divergence guard needs their last-known branch state; they just never - * render. + * Sidebar order for foreign rows: reachable machines first, each group by most + * recent activity. A dropped machine's lanes are still worth seeing — that is + * the point of dimming rather than hiding — but they are not what you are about + * to act on, so they sink below the live ones instead of interleaving with them. */ -export function selectReachableCrossMachineRows( +export function orderCrossMachineRows( rows: readonly CrossMachineLaneRow[], ): CrossMachineLaneRow[] { - return rows.filter((row) => row.online); + return [...rows].sort((left, right) => { + if (left.online !== right.online) return left.online ? -1 : 1; + return laneActivityRank(right) - laneActivityRank(left); + }); } /** @@ -367,8 +401,9 @@ export function selectReachableCrossMachineRows( * 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. * - * Callers pass reachable rows only: an unreachable machine has no rows on screen - * to mark, and must not tip an online lane into "same branch elsewhere". + * Offline machines are included, and their branches still count toward + * "same branch elsewhere": a branch you cannot see right now is exactly the one + * you are most likely to strand commits behind. */ export function resolveCrossMachineLaneMarkers( rows: readonly CrossMachineLaneRow[], @@ -396,7 +431,7 @@ export function resolveCrossMachineLaneMarkers( const branch = normalizeBranchRef(row.lane.branchRef); const sameBranchElsewhere = (machinesByBranch.get(branch)?.size ?? 0) >= 2; const mode: CrossMachineLaneMarker["mode"] = - manyForeignMachines || sameBranchElsewhere ? "name" : "glyph"; + !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 @@ -405,6 +440,7 @@ export function resolveCrossMachineLaneMarkers( markers.set(markerKey, { machineId: row.machineId, machineName: row.machineName, + online: row.online, mode, title: row.machineName, sameBranchElsewhere, @@ -599,10 +635,30 @@ type SyncRuntime = { refreshTimer: ReturnType | null; refreshInFlight: boolean; refreshQueued: boolean; - /** First moment each currently-unreachable machine stopped reporting connected. */ - droppedAtMsByMachineId: Map; - /** Re-evaluates reachability when the oldest grace window lapses. */ + /** Open drop record per machine that is currently not connected. */ + dropsByMachineId: Map; + /** Re-evaluates reachability when the next drop deadline lapses. */ graceTimer: ReturnType | null; + /** Last `lane.list` read per machine, keyed the same way the store is. */ + laneReadAtMsByMachineId: Map; +}; + +/** + * What we know about one machine's current disconnection. + * + * A drop is believed only once a reconnect attempt has run to completion and + * failed — `connecting` seen while dropped, then a non-connected state — because + * that is the difference between "the link blipped" and "the machine is gone". + * `lastAttemptedAt` cannot answer this on its own: a failed RPC over an already + * established connection stamps it too (`markCallFailure`), which is the very + * event that starts most drops. + */ +type MachineDrop = { + droppedAtMs: number; + /** A dial has been observed since the drop. */ + sawAttempt: boolean; + /** That dial has since finished without reaching `connected`. */ + attemptFailed: boolean; }; const runtime: SyncRuntime = { @@ -623,10 +679,20 @@ const runtime: SyncRuntime = { refreshTimer: null, refreshInFlight: false, refreshQueued: false, - droppedAtMsByMachineId: new Map(), + dropsByMachineId: new Map(), graceTimer: null, + laneReadAtMsByMachineId: new Map(), }; +/** + * Nobody is looking at the sidebar, so nothing is worth reading for it. The + * union's whole cost is remote reads on other people's machines; a hidden window + * pays it for a list that will be refreshed the moment it comes back. + */ +function isDocumentVisible(): boolean { + return typeof document === "undefined" || document.visibilityState !== "hidden"; +} + function sameScope(a: CrossMachineLaneScope, b: CrossMachineLaneScope): boolean { return ( a.scopeKey === b.scopeKey @@ -714,6 +780,27 @@ function isMachineEligibleNow(machineId: string): boolean { return resolveEligibleMachines().some((option) => option.id === machineId); } +/** + * Whether this tick should re-read a machine's lanes as well as its chats. + * First read always does; after that the lane list has its own slow cadence. + */ +function shouldReadLanes(machineId: string, nowMs: number): boolean { + const readAtMs = runtime.laneReadAtMsByMachineId.get(machineId); + return readAtMs == null || nowMs - readAtMs >= FOREIGN_LANE_REFRESH_MS; +} + +/** Lanes referenced by chats we can see but have no lane row for. */ +function hasUnknownLaneReference( + machineId: string, + sessions: readonly TerminalSessionSummary[], +): boolean { + const known = new Set( + (rootAppStoreApi.getState().crossMachineLanesByMachineId[machineId]?.lanes ?? []) + .map((lane) => lane.id), + ); + return sessions.some((session) => session.laneId && !known.has(session.laneId)); +} + async function readMachine( machineId: string, machineName: string, @@ -725,17 +812,20 @@ async function readMachine( const store = rootAppStoreApi.getState(); const callAction = window.ade?.remoteRuntime?.callAction; if (!callAction) return; + const readLanes = () => + withTimeout( + callAction(targetId, projectId, { + domain: "lane", + action: "list", + args: { includeArchived: false, includeStatus: true }, + }), + MACHINE_READ_TIMEOUT_MS, + `lane.list on ${machineName}`, + ); try { + const wantLanes = shouldReadLanes(machineId, Date.now()); const [laneResult, sessionResult] = await Promise.all([ - withTimeout( - callAction(targetId, projectId, { - domain: "lane", - action: "list", - args: { includeArchived: false, includeStatus: true }, - }), - MACHINE_READ_TIMEOUT_MS, - `lane.list on ${machineName}`, - ), + wantLanes ? readLanes() : null, withTimeout( callAction(targetId, projectId, { domain: "session", @@ -749,6 +839,17 @@ async function readMachine( // Cancellation: a scope change or a newer snapshot bumped the generation // while this read was in flight, so its answer is about a different world. if (generation !== runtime.generation) return; + const sessions = decodeForeignSessions(sessionResult.result); + // A chat is rendered under its lane, so a chat launched on a lane this + // machine has never reported would be invisible until the slow lane cadence + // came round. Seeing one is the signal to pay for the lane read now. + let lanes = laneResult ? decodeForeignLanes(laneResult.result) : null; + if (!lanes && hasUnknownLaneReference(machineId, sessions)) { + const catchUp = await readLanes(); + if (generation !== runtime.generation) return; + lanes = decodeForeignLanes(catchUp.result); + } + if (lanes) runtime.laneReadAtMsByMachineId.set(machineId, Date.now()); store.mergeCrossMachineLanes({ machineId, machineName, @@ -756,16 +857,13 @@ async function readMachine( projectId, binding, // Confirm reachable, never resurrect. Reachability is owned by the - // connection snapshot and its grace window; a read that was in flight - // across a disconnect must not flip a machine the window already hid back - // on — nothing would hide it again until the next snapshot happens to - // fire. Omitting the flag retains whatever the snapshot path decided. + // connection snapshot and its drop deadlines; a read that was in flight + // across a disconnect must not flip a machine the snapshot path already + // dimmed back on — nothing would dim it again until the next snapshot + // happens to fire. Omitting the flag retains that verdict. ...(isMachineEligibleNow(machineId) ? { online: true } : {}), - lanes: decodeForeignLanes(laneResult.result), - sessions: reconcileCrossMachineOptimisticSessions( - binding, - decodeForeignSessions(sessionResult.result), - ), + ...(lanes ? { lanes } : {}), + sessions: reconcileCrossMachineOptimisticSessions(binding, sessions), error: null, }); } catch (error) { @@ -787,16 +885,19 @@ async function readThisMachine( generation: number, ): Promise { const store = rootAppStoreApi.getState(); - try { - const [lanes, sessions] = await Promise.all([ - withTimeout( - window.ade.lanes.list( - { includeArchived: false, includeStatus: true }, - binding, - ), - MACHINE_READ_TIMEOUT_MS, - "lane.list on This Mac", + const readLanes = () => + withTimeout( + window.ade.lanes.list( + { includeArchived: false, includeStatus: true }, + binding, ), + MACHINE_READ_TIMEOUT_MS, + "lane.list on This Mac", + ); + try { + const wantLanes = shouldReadLanes(THIS_MACHINE_ID, Date.now()); + const [laneResult, sessions] = await Promise.all([ + wantLanes ? readLanes() : null, withTimeout( window.ade.sessions.list( { limit: FOREIGN_SESSION_LIMIT }, @@ -807,6 +908,12 @@ async function readThisMachine( ), ]); if (generation !== runtime.generation) return; + let lanes = laneResult; + if (!lanes && hasUnknownLaneReference(THIS_MACHINE_ID, sessions)) { + lanes = await readLanes(); + if (generation !== runtime.generation) return; + } + if (lanes) runtime.laneReadAtMsByMachineId.set(THIS_MACHINE_ID, Date.now()); store.mergeCrossMachineLanes({ machineId: THIS_MACHINE_ID, machineName: THIS_MACHINE_NAME, @@ -814,7 +921,7 @@ async function readThisMachine( projectId: null, binding, online: true, - lanes, + ...(lanes ? { lanes } : {}), sessions: reconcileCrossMachineOptimisticSessions(binding, sessions), error: null, }); @@ -885,6 +992,10 @@ function scheduleRefresh(): void { clearTimeout(runtime.refreshTimer); runtime.refreshTimer = null; } + // Hidden windows read nothing at all. The visibility listener in `attach` + // calls straight back here on the way in, so the list is refreshed once, + // immediately, when it can actually be seen again. + if (!isDocumentVisible()) return; if (runtime.refreshInFlight) { runtime.refreshQueued = true; return; @@ -892,10 +1003,17 @@ function scheduleRefresh(): void { if (runtime.timer) return; runtime.timer = setTimeout(() => { runtime.timer = null; + // A refresh outlives its own runtime: reads are bounded but slow, and + // teardown does not cancel them. Bookkeeping from a run whose runtime has + // since been torn down would re-arm a poll timer nobody is subscribed to, + // and leave `refreshInFlight` set for whoever mounts next — which then never + // schedules anything at all. + const lifecycle = runtime.lifecycle; runtime.refreshInFlight = true; void runRefresh() .catch(() => {}) .finally(() => { + if (lifecycle !== runtime.lifecycle) return; runtime.refreshInFlight = false; if (runtime.refCount === 0) return; if (runtime.refreshQueued) { @@ -903,6 +1021,7 @@ function scheduleRefresh(): void { scheduleRefresh(); return; } + if (!isDocumentVisible()) return; runtime.refreshTimer = setTimeout(() => { runtime.refreshTimer = null; scheduleRefresh(); @@ -922,80 +1041,166 @@ function scheduleRefresh(): void { * list dropped it, so its rows were never refreshed either: permanently visible, * permanently stale. */ -function resolveEligibleMachines(): LaneMachineOption[] { +function resolveMachineOptions(): LaneMachineOption[] { const scope = runtime.scope; return deriveLaneMachineOptions({ connections: runtime.connections, boundTargetId: scope.boundTargetId, repoOriginUrl: scope.repoOriginUrl ?? resolveBoundRepoOriginUrl(scope), repoDisplayName: scope.repoDisplayName, - }).filter( - (option) => - option.id !== THIS_MACHINE_ID - && option.id !== (scope.boundTargetId ?? THIS_MACHINE_ID) - && option.targetId - && option.project?.projectId - && option.repoMatch === "matched", + }); +} + +function isEligibleMachineOption(option: LaneMachineOption): boolean { + const scope = runtime.scope; + return ( + option.id !== THIS_MACHINE_ID + && option.id !== (scope.boundTargetId ?? THIS_MACHINE_ID) + && Boolean(option.targetId) + && Boolean(option.project?.projectId) + && option.repoMatch === "matched" ); } +function resolveEligibleMachines(): LaneMachineOption[] { + return resolveMachineOptions().filter(isEligibleMachineOption); +} + +/** What the newest snapshot says about one machine, whether connected or not. */ +type MachineConnectivity = { + /** `null` when the machine is not in the connection snapshot at all. */ + state: RemoteRuntimeConnectionState | null; + /** `null` unless the machine is connected — the match needs its project list. */ + repoMatch: LaneMachineRepoMatch | null; + eligible: boolean; +}; + +function resolveMachineConnectivity(): Map { + const byMachineId = new Map(); + for (const connection of runtime.connections) { + byMachineId.set(connection.target.id, { + state: connection.state, + repoMatch: null, + eligible: false, + }); + } + // `deriveLaneMachineOptions` only returns connected machines, so anything it + // names is connected and carries a usable repo verdict. + for (const option of resolveMachineOptions()) { + if (option.id === THIS_MACHINE_ID) continue; + byMachineId.set(option.id, { + state: "connected", + repoMatch: option.repoMatch, + eligible: isEligibleMachineOption(option), + }); + } + return byMachineId; +} + /** - * Marks machines reachable/unreachable from the latest connection snapshot. + * Decides, from the latest connection snapshot, which machines Work shows as + * live, which it dims, and which it forgets. + * + * Three verdicts, and the difference between them is what this whole module is + * about: * - * Connection state is the ONLY input. `runRefresh`'s narrower target list - * decides which machines are worth *reading*, not which are visible — folding it - * in here would give the two sources contradictory opinions about the same - * machine every few seconds. + * - LIVE. Connected and still hosting this repository. + * - DIMMED. Not connected, and a reconnect attempt has since run to completion + * and failed (or the target is idle, so no attempt is coming). Its lanes and + * chats stay on screen, collapsed and inert, because a machine being asleep + * does not make the work on it stop existing — and yanking a lane group out of + * the list on every wifi blip is what made machines look like they vanish. + * Believing a drop takes at least `UNREACHABLE_FLOOR_MS`, and at most + * `UNREACHABLE_CEILING_MS` when no attempt ever completes. + * - FORGOTTEN. Only two things earn removal: the machine is gone from the + * connection snapshot entirely (unpaired or deleted — nothing will ever + * refresh it again), or it has been dimmed for `OFFLINE_RETENTION_MS`. * - * Reconnecting is not the same as offline. `connect()` publishes `connecting` - * before every attempt — including automatic ones — and a single failed liveness - * ping flips a target to `error` and immediately redials. Now that unreachable - * means *removed from the sidebar*, reacting to the first non-connected snapshot - * would make a websocket blip or a sleep/wake yank a machine's whole lane group - * out of the list and animate it back a second later. So a drop only counts once - * it has persisted for `RECONNECT_GRACE_MS`, measured from the first drop rather - * than the latest snapshot; coming back is applied instantly. + * A machine we ARE connected to but cannot re-prove the repository on is left + * exactly as it was. Absence of proof is not proof of absence: a project list + * that has not caught up after a reconnect must not read as "the repo is gone". + * Only a connected machine that positively reports the repository missing is + * dropped, which is what keeps "if it can't be refreshed, it isn't shown" true + * for the case #941 was about. */ function applyReachability(): void { - const connected = new Set(resolveEligibleMachines().map((option) => option.id)); const store = rootAppStoreApi.getState(); const nowMs = Date.now(); + const connectivity = resolveMachineConnectivity(); const reachable: string[] = []; + const forgotten: string[] = []; let soonestDeadlineMs: number | null = null; + const noteDeadline = (deadlineMs: number) => { + if (soonestDeadlineMs == null || deadlineMs < soonestDeadlineMs) { + soonestDeadlineMs = deadlineMs; + } + }; - for (const machineId of connected) { - runtime.droppedAtMsByMachineId.delete(machineId); + for (const [machineId, machine] of connectivity) { + if (!machine.eligible) continue; + runtime.dropsByMachineId.delete(machineId); reachable.push(machineId); } for (const entry of Object.values(store.crossMachineLanesByMachineId)) { + const machineId = entry.machineId; // This Mac is not a connection target and is always reachable; holding a - // deadline for it would leak a map entry that nothing can ever clear. - if (entry.machineId === THIS_MACHINE_ID) continue; - if (connected.has(entry.machineId)) continue; - // Already hidden: nothing to hold on to, and no timer to keep alive. - if (!entry.online) { - runtime.droppedAtMsByMachineId.delete(entry.machineId); + // drop record for it would leak a map entry nothing can ever clear. + if (machineId === THIS_MACHINE_ID) continue; + const machine = connectivity.get(machineId); + if (machine?.eligible) continue; + + if (!machine) { + forgotten.push(machineId); continue; } - const droppedAtMs = runtime.droppedAtMsByMachineId.get(entry.machineId) ?? nowMs; - runtime.droppedAtMsByMachineId.set(entry.machineId, droppedAtMs); - const deadlineMs = droppedAtMs + RECONNECT_GRACE_MS; - if (deadlineMs <= nowMs) continue; - reachable.push(entry.machineId); - if (soonestDeadlineMs == null || deadlineMs < soonestDeadlineMs) { - soonestDeadlineMs = deadlineMs; + if (machine.state === "connected") { + if (machine.repoMatch === "missing") { + forgotten.push(machineId); + continue; + } + runtime.dropsByMachineId.delete(machineId); + if (entry.online) reachable.push(machineId); + continue; } + + const drop = runtime.dropsByMachineId.get(machineId) + ?? { droppedAtMs: nowMs, sawAttempt: false, attemptFailed: false }; + if (machine.state === "connecting") drop.sawAttempt = true; + else if (drop.sawAttempt) drop.attemptFailed = true; + runtime.dropsByMachineId.set(machineId, drop); + + const elapsedMs = nowMs - drop.droppedAtMs; + // `idle` means the target is not dialing and will not start on its own, so + // waiting for a failed attempt would wait forever. + const answered = drop.attemptFailed || machine.state === "idle"; + if (elapsedMs < UNREACHABLE_FLOOR_MS || !(answered || elapsedMs >= UNREACHABLE_CEILING_MS)) { + reachable.push(machineId); + noteDeadline(drop.droppedAtMs + (answered ? UNREACHABLE_FLOOR_MS : UNREACHABLE_CEILING_MS)); + continue; + } + if (elapsedMs >= OFFLINE_RETENTION_MS) { + forgotten.push(machineId); + continue; + } + noteDeadline(drop.droppedAtMs + OFFLINE_RETENTION_MS); } + if (forgotten.length > 0) { + for (const machineId of forgotten) { + runtime.dropsByMachineId.delete(machineId); + runtime.laneReadAtMsByMachineId.delete(machineId); + } + store.dropCrossMachineLanes(forgotten); + } store.setCrossMachineMachinesOnline(reachable); if (runtime.graceTimer) { clearTimeout(runtime.graceTimer); runtime.graceTimer = null; } - // Nothing else re-runs this on its own: a machine held through its grace - // window produces no further snapshot, so without this the row would stay - // visible indefinitely after a real disconnect. + // Nothing else re-runs this on its own: a machine held through its floor + // produces no further snapshot, so without this the row would stay bright + // indefinitely after a real disconnect. if (soonestDeadlineMs != null) { runtime.graceTimer = setTimeout(() => { runtime.graceTimer = null; @@ -1004,13 +1209,14 @@ function applyReachability(): void { } } -/** Forgets every pending drop deadline. Used by teardown and by scope changes. */ -function resetReachabilityGrace(): void { +/** Forgets every open drop record. Used by teardown and by scope changes. */ +function resetReachabilityTracking(): void { if (runtime.graceTimer) { clearTimeout(runtime.graceTimer); runtime.graceTimer = null; } - runtime.droppedAtMsByMachineId.clear(); + runtime.dropsByMachineId.clear(); + runtime.laneReadAtMsByMachineId.clear(); } function applySnapshot(snapshot: RemoteRuntimeConnectionSnapshot): void { @@ -1031,6 +1237,17 @@ function attach(): void { if (unsubscribeSessions) runtime.disposers.push(unsubscribeSessions); const unsubscribeLanes = window.ade?.lanes?.onLifecycleEvent?.(() => scheduleRefresh()); if (unsubscribeLanes) runtime.disposers.push(unsubscribeLanes); + // The refresh loop stops itself while the window is hidden, so coming back is + // the only thing that can restart it. + if (typeof document !== "undefined") { + const onVisibilityChange = () => { + if (isDocumentVisible()) scheduleRefresh(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + runtime.disposers.push(() => { + document.removeEventListener("visibilitychange", onVisibilityChange); + }); + } // Lifecycle-guarded, deliberately NOT generation-guarded: if every consumer // unmounts before this first read resolves, applying it would write // reachability and arm a grace timer for a runtime nobody is subscribed to. @@ -1060,8 +1277,9 @@ function detach(): void { clearTimeout(runtime.refreshTimer); runtime.refreshTimer = null; } - resetReachabilityGrace(); + resetReachabilityTracking(); runtime.refreshQueued = false; + runtime.refreshInFlight = false; for (const dispose of runtime.disposers.splice(0)) { try { dispose(); @@ -1090,7 +1308,7 @@ export function startCrossMachineLaneSync(scope: CrossMachineLaneScope): () => v // The new scope does wipe every machine slice, though, so a deadline carried // over from the old one could hide a machine with no grace at all the moment // it reappears here. - resetReachabilityGrace(); + resetReachabilityTracking(); rootAppStoreApi.getState().applyCrossMachineLaneScope(scope.scopeKey); } runtime.scope = scope; @@ -1235,17 +1453,15 @@ export function useCrossMachineLaneUnion(active = true): CrossMachineUnion { [localLanes, machines, projectBinding], ); return useMemo(() => { - // Offline machines are dropped here, once, for everything the sidebar sees. - const reachableRows = selectReachableCrossMachineRows(rows); - const foreignRows = reachableRows - .filter((row) => !row.isActiveBinding) - .sort((left, right) => laneActivityRank(right) - laneActivityRank(left)); + 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; return { foreignRows, - markersByLaneId: resolveCrossMachineLaneMarkers(reachableRows), + markersByLaneId: resolveCrossMachineLaneMarkers(rows), }; }, [rows]); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3b7b2e6e3..c8ead3691 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -835,7 +835,7 @@ Electron renderer runtime does **not** wrap the app in `React.StrictMode`. Brows - Narrow selectors on components to minimize re-renders. - `refreshLanes` accepts independent lane-status and lane-snapshot flags. Callers can refresh cheap runtime snapshot decorations without recomputing git status, or update git status without rebuilding conflict/rebase/auto-rebase overlays; statusless refreshes preserve the previous `LaneStatus`/`parentStatus` in store so the UI does not flicker to unknown git state. - Per-project work-view state keyed by project identity (`WorkProjectViewState`): local bindings use their root path, while remote bindings use `OpenProjectBinding.key` (`remote::`). Alongside Work filters, collapsed section ids, and right-sidebar state, version 3 includes the Lanes tab's filter, pinned lane ids, and expanded lane id. Version 4 adds the Work-sidebar-only `workPinnedLaneIds`, lane sort mode, sparse manual lane order, and structured session chips; normalization is additive, so a v3 blob retains the former Created order with no Work pins or chips. It keeps Settled reachable as a quiet collapsed tail: Status/Time mode use `status:settled`, Lane mode uses explicit `settled-open:` markers, and a fully quiet lane uses the inverted `lane-open:` marker. There is no independent Tiers/Show settled filter. The one-time status-collapse migration is gated against its own version-2 threshold, so later additive schema bumps do not re-collapse a section the user expanded. Persistence under `ade.workViewState.v1` is a scoped delta read-modify-write: every mounted project store upserts or deletes only the project/lane keys it owns instead of flushing its stale copy of the whole map. `refreshLanes` prunes lane scopes only from a non-empty lane inventory and only inside that store's project scope. `registerProjectSurfaceStore` / `workViewStoreForProject` route project-specific writes made by `AppShell` and `TopBar`, which render above `AppStoreProvider`, to the owning surface store. Lane/status deeplinks are transient view overrides and user filter, grouping, chip, pin, or ordering changes return to and then update the saved base. The right-edge fields are `workSidebarOpen`, `workSidebarTab` (`"git" | "files" | "ios" | "app-control" | "browser"`), and `workSidebarWidthPct` (clamped 26–55). The sidebar consolidates lane-scoped tools that were previously split across separate floating panes; per-chat iOS / App Control drawers still exist on `AgentChatPane` but are suppressed when the chat is mounted as a Work tile so the sidebar owns those surfaces at lane scope. Remote-bound Work sidebars expose only the runtime-backed Git and Files tabs; local-only iOS Simulator, App Control, and Browser panes stay hidden. The `browser` tab is not lane-scoped on local bindings: each ADE window/project keeps its own tab collection and inspect state, while browser authentication storage is global to the installation/channel through `persist:ade-browser`. -- Cross-machine Work union. `crossMachineLanesByMachineId` + `crossMachineLaneScopeKey` (fed by `renderer/state/crossMachineLanes.ts`) hold every *other* connected machine's lanes and sessions for the repository the active tab is showing, so the Work sidebar can list chats in flight anywhere without changing the tab's binding. Keyed by machine because a lane owns its machine (`lanes.worktree_path` is an absolute path on exactly one machine) and chats inherit theirs through `laneId` — there is no per-chat machine field. `mergeCrossMachineLanes` retains omitted `lanes`/`sessions` so a failed read leaves the machine's rows on screen, and `setCrossMachineMachinesOnline` retains rather than deletes an entry that goes offline — the entry stays for the push-divergence guard while `useCrossMachineLaneUnion` hides its lanes, chats, and markers from the Work sidebar through the single `selectReachableCrossMachineRows` narrowing (there is no unfiltered `rows` escape hatch on `CrossMachineUnion`, and `CrossMachineLaneMarker` has no offline form). Reachability is derived from connection state alone, and a drop only hides the machine once it has persisted for `RECONNECT_GRACE_MS` (6 s), so the `connecting`/`error` states a redial or sleep/wake publishes do not reflow the sidebar; reconnecting is applied instantly. The scope key is per repository, so a project-tab switch invalidates the union wholesale. A detached chat launch whose runtime pin differs from the active binding seeds an optimistic summary directly into the owning machine slice; the binding/session-keyed pending record survives stale in-flight list responses and is replaced by the authoritative row with the same stable session id (or pruned on delete/expiry). This keeps the active binding free of foreign UUID-lane placeholders while avoiding a blank interval before the next remote list arrives. Foreign lanes use the same `sessionFilingBucket` active/snoozed/settled partition, fully quiet collapsed header, and quiet-tail renderer as local lanes, with composite machine/lane persistence keys. Their **Manage lane** dialog and mutations carry the same owning binding. Sessions whose referenced lane is absent render as explicit warning-tinted **Orphaned sessions** groups with a refresh-only recovery action; ADE preserves the sessions and never interprets unknown ownership as permission to mutate the active machine. Refreshes ride the connection-snapshot subscription plus existing lane-lifecycle/session-changed events (coalesced, bounded, timed out, capped in parallelism); nothing polls, and foreign reads never gate the local list. `selectOtherMachineBranchStates` is the memoized selector the push-divergence guard reads at click time. +- Cross-machine Work union. `crossMachineLanesByMachineId` + `crossMachineLaneScopeKey` (fed by `renderer/state/crossMachineLanes.ts`) hold every *other* connected machine's lanes and sessions for the repository the active tab is showing, so the Work sidebar can list chats in flight anywhere without changing the tab's binding. Keyed by machine because a lane owns its machine (`lanes.worktree_path` is an absolute path on exactly one machine) and chats inherit theirs through `laneId` — there is no per-chat machine field. `mergeCrossMachineLanes` retains omitted `lanes`/`sessions` so a failed read leaves the machine's rows on screen, and `setCrossMachineMachinesOnline` flags rather than deletes an entry that goes offline — the sidebar renders it dimmed, collapsed, and inert, and the retained slice also backs the push-divergence guard. Presence is decided in one place, `applyReachability`, from connection state alone: a drop is believed only after a reconnect attempt has completed and failed (`connecting` observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling for a dial that never finishes and an immediate verdict for an `idle` target, so the `connecting`/`error` states a redial or sleep/wake publishes do not reflow the sidebar; reconnecting is applied instantly. Only `dropCrossMachineLanes` deletes, and only for a target gone from the snapshot, a connected machine that positively reports the repository missing, or 24 hours unreachable. The scope key is per repository, so a project-tab switch invalidates the union wholesale. A detached chat launch whose runtime pin differs from the active binding seeds an optimistic summary directly into the owning machine slice; the binding/session-keyed pending record survives stale in-flight list responses and is replaced by the authoritative row with the same stable session id (or pruned on delete/expiry). This keeps the active binding free of foreign UUID-lane placeholders while avoiding a blank interval before the next remote list arrives. Foreign lanes use the same `sessionFilingBucket` active/snoozed/settled partition, fully quiet collapsed header, and quiet-tail renderer as local lanes, with composite machine/lane persistence keys. Their **Manage lane** dialog and mutations carry the same owning binding. Sessions whose referenced lane is absent render as explicit warning-tinted **Orphaned sessions** groups with a refresh-only recovery action; ADE preserves the sessions and never interprets unknown ownership as permission to mutate the active machine. Refreshes ride the connection-snapshot subscription plus existing lane-lifecycle/session-changed events (coalesced, bounded, timed out, capped in parallelism) and a fallback loop for machines with no renderer change feed; that loop is paused entirely while the window is hidden, re-reads chats every 10 s, and re-reads lanes on a 30 s cadence (or immediately when a chat names a lane the machine has never reported), because `lane.list` with `includeStatus` costs a git status per lane on the other machine. Foreign reads never gate the local list. `selectOtherMachineBranchStates` is the memoized selector the push-divergence guard reads at click time. - Project tab bookkeeping. `openProjectTabRoots: string[]` tracks local roots open in the window (mirrored to the main process via `ade.app.setWindowProjectTabs` so background services keep those projects warm); `openRemoteProjectTabs` tracks full remote bindings so inactive remote tabs remain first-class retained surfaces. `ProjectTabHost` applies one shared eight-surface LRU across local and remote tabs: inactive mounted surfaces are hidden, inert, and animation-paused, while an open surface that falls outside the bound snapshots its scoped state back into the root caches before unmounting. `projectInfoByRoot: Record` caches local `ProjectInfo` payloads for tab favicons and offline tab rendering. - Stale-while-revalidate switch caches. `laneSelectionByProject` remembers the `{ laneId, sessionId }` selection per project identity so switching tabs lands on the lane/chat the user last had open instead of "first lane". `laneCacheByProject` mirrors the last good `{ lanes, laneSnapshots }`; local and remote switches apply it immediately (no spinner, no chat-pane unmount) and refresh silently in the background. `sessionsCacheByProject` does the same for `useWorkSessions` so chat tabs and terminal grids do not blank during a tab swap. `projectRouteStorage.ts` persists the last route under the binding key, independent of whether the surface is currently mounted. Cache pruning retains every open local root and remote binding key; tab close and target disconnect deliberately evict only their affected remote state. The two eviction paths are distinct: `evictProjectState(key)` + `removeStoredProjectRoute(key)` is the full "forget this surface" wipe used by an explicit tab close and by removing a machine, while `evictProjectDataCaches(key)` is the disconnect-only sibling that drops just the snapshots that can go stale while a remote is unreachable (`laneCacheByProject`, `laneSelectionByProject`, `sessionsCacheByProject`, and the persisted lane cache) and preserves `workViewByProject` / `laneWorkViewByScope` plus the stored route so reconnecting restores the chat or tile that was open. `closeProject({ preserveRemoteViewState: true })` applies the same narrower rule when a disconnect closes the last remaining tab. - `projectRevision` is a monotonically incrementing counter bumped inside `setProject` whenever the active project root actually changes. Long-lived renderer-side caches (most notably the module-level xterm runtime cache in `TerminalView.tsx`) combine it with the project identity key, so identical paths on different remote targets cannot share PTY runtimes. All project-transition paths (`refreshProject`, `openRepo`, `switchProjectToPath`, `closeProject`) go through `setProject` to keep the counter honest. diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 7334cad2a..439c6236c 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -178,16 +178,18 @@ relay payload E2E encryption is planned security work. See the trust boundary in by the connection-snapshot subscription and existing lane-lifecycle / session-changed events (coalesced, no polling); foreign reads are bounded, timed out, capped at four machines in parallel, and never gate the local list. - A machine that drops **leaves the Work sidebar entirely**: - `selectReachableCrossMachineRows` narrows the union inside - `useCrossMachineLaneUnion`, so its lanes, chats, and machine markers stop - rendering and its branches stop counting toward "same branch elsewhere". Its - store slice is retained rather than deleted, because the push-divergence guard - needs a dropped machine's last-known branch state. Reachability is derived from - connection state alone and only after a `RECONNECT_GRACE_MS` (6 s) window, so - the `connecting` / `error` states a redial or a sleep/wake publishes cannot - reflow half the sidebar away and animate it back a second later; coming back is - applied instantly. The union is + A machine that drops is **dimmed, not deleted**: its lanes and chats stay on + screen, collapsed and inert, with the offline form of the machine marker naming + it. Rows leave for two reasons only — the machine is gone from the connection + snapshot (unpaired or removed), or it has been unreachable for 24 hours. + Believing a drop takes a completed, failed reconnect attempt (`connecting` + observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling + for a dial that never finishes and an immediate verdict for an `idle` target + that will not redial at all: every redial publishes `connecting` and a single + failed liveness ping flips a target to `error`, so a shorter rule dims the + sidebar on every wifi blip. A machine that is connected but cannot re-prove + this repository keeps its last verdict; only one that positively reports the + repository missing is dropped. Coming back is applied instantly. The union is scoped per repository, so switching project tabs invalidates it wholesale. `selectOtherMachineBranchStates` is the derived-state seam the push guard reads at click time. @@ -370,11 +372,11 @@ Run all follow it. Two things are deliberately wider than that: - **The Work sidebar is a union.** It shows chats in flight on *every* connected machine for this repository, regardless of which machine the tab is bound to. Lanes not on This Mac carry a small monochrome machine marker that promotes to - the machine's name when a glyph alone would be ambiguous (two or more foreign - machines are on screen, or the same branch exists elsewhere). Foreign lanes - appear only when they have chats — the union is about work in flight, not an - inventory. A machine that goes offline leaves the sidebar entirely: its lanes, - chats, and markers are hidden until it reconnects. + 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 same branch exists + elsewhere). Foreign lanes appear only when they have chats — the union is about + work in flight, not an inventory. A machine that goes offline keeps its rows, + dimmed, folded shut, and inert, and sinks below the reachable machines. - **A chat runs on its own lane's machine.** Opening a chat from the union streams it from the machine that owns its lane, with its calls pinned to that machine's runtime; the tab stays bound where it was. Clicking a foreign diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 9d27e1204..eb8e18d2a 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -546,12 +546,14 @@ Renderer surfaces: CLI rows switch the tab to the owning project first because a PTY has no per-session runtime pin. Row/lane context actions pass the same binding so mutations cannot fall through to the active machine. A machine that goes - offline leaves the sidebar entirely — `useCrossMachineLaneUnion` drops its - lanes, chats, and markers, and its branches stop counting toward - "same branch elsewhere" — because a retained row reads as live work you can - act on and every action on it fails. The slice stays in the store for the - push-divergence guard, which needs the last-known branch state of an - unreachable machine. A lane with shared delete progress is dimmed and + offline keeps its rows, dimmed and folded shut: every card is inert and reads + " is offline", the lane context menu's machine-bound actions are + disabled from live store state rather than a flag captured at right-click time, + and the group sorts below the reachable machines. Its branches still count + toward "same branch elsewhere", because commits stranded on a machine you + cannot reach are the ones most worth naming — the same reason the + push-divergence guard reads the retained slice. A lane with shared delete + progress is dimmed and interaction-blocked: its lane-group header shows the deletion status, and every session card for that lane is disabled in lane, status, and time organization modes. The bottom Add Lane button opens @@ -582,17 +584,25 @@ 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. - It also owns visibility: `applyReachability` marks a machine reachable purely - from the connection snapshot (never from the narrower read-target list, which - would give the two sources contradictory opinions), and - `selectReachableCrossMachineRows` is the single place offline machines are - dropped from what the sidebar sees. A drop only counts once it has persisted - for `RECONNECT_GRACE_MS` (6 s), measured from the first non-connected snapshot - rather than the latest, because every redial publishes `connecting` and a - single failed liveness ping flips a target to `error`; reconnecting is applied - immediately. A timer re-runs the check when the oldest grace window lapses, - since a machine held through its window produces no further snapshot on its - own, and the deadlines are cleared on teardown and on scope change. + 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 + failed — `connecting` seen while dropped, then a non-connected state — with a + 45 s floor, a 120 s ceiling for a dial that never finishes, and an immediate + verdict for an `idle` target that will not redial. `lastAttemptedAt` cannot + answer this alone: a failed RPC over an established connection stamps it too, + and that is the event most drops start with. Removal is reserved for a target + missing from the snapshot, a connected machine that positively reports the + repository missing, and 24 hours unreachable (`dropCrossMachineLanes`). A timer + re-runs the check at the next deadline, since a machine held through its floor + produces no further snapshot on its own, and the records are cleared on + teardown and on scope change. + Reads are visibility-gated: the loop stops entirely while the window is hidden + and refreshes once on the way back. Chats are re-read every 10 s; the lane list + has its own 30 s cadence because `lane.list` with `includeStatus` resolves a + git status per lane and writes a state-snapshot row per lane on the other + machine — a chat referencing a lane that machine has never reported forces the + lane read immediately, so nothing is invisible while it waits. - `apps/desktop/src/renderer/components/terminals/SessionCard.tsx` — per-session card (status dot, title, preview line, tool type, lane, delta chips). Any session with `orchestrationParentSessionId` renders a diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 140d5ba1c..d831e7b9f 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -176,11 +176,14 @@ cards use the same collapsed quiet tails as local lanes. A fully quiet foreign lane starts as the same minimal header with inline counts and uses `lane-open::` for explicit expansion. When active work returns, `SessionListPane` clears that marker so the next all-quiet state starts -collapsed. Card selection and context actions still carry the owning runtime -binding. **Manage lane** opens the shared dialog with every read and mutation -pinned to that machine. Offline machines have no rows here at all — the union -filters them out — so the only disabled foreign row left is one whose reachable -machine has not resolved a project binding yet. +collapsed — except on an offline machine, whose chats only look active because +that is the last thing it reported. Card selection and context actions still +carry the owning runtime binding. **Manage lane** opens the shared dialog with +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. In-flight chat handoffs are rendered as temporary placeholder cards in the same sidebar. `TerminalsPage` pulls matching `HandoffLaunchJob` From 197a3485828d3790312a67a598e5d9bd4f01b24e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:08:36 -0400 Subject: [PATCH 2/4] perf(attention): cut idle Attention cadence and make a hung refresh recover Attention kept three background pollers running regardless of whether anything was on screen, and one RPC path could pin the UI on "syncing" for ten minutes. - `callAttention` inherited the runtime client's 10-minute default while every other sync-domain call uses 30s. It now passes the sync-domain budget, and `callSync` takes the timeout as an option so no other caller changes. - `refreshAttentionSnapshot` deduped on a module-level promise that a wedged call never settled, so every later refresh returned the same dead promise and the UI never left "syncing". A 45s renderer backstop - deliberately above the 30s main-process budget, so real host errors still win - now lands in the existing degraded/retry path and clears the dedupe. - The notch helper polled every 15s from the moment it spawned, including with the screen locked or asleep. It now reconciles its cadence: 15s only while it has an anchored surface and the screen is awake, 60s otherwise, driven by `powerMonitor` lock/suspend in main. - The presence POST ran at a fixed 30s. Hidden windows now report at 120s and send immediately on the way back; `blur` still reports the foreground change at once, so nothing is learned later than before. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/main/main.ts | 6 +- .../attention/attentionNotchHelper.test.ts | 30 +++- .../attention/attentionNotchHelper.ts | 39 ++++- .../localRuntimeConnectionPool.test.ts | 25 ++-- .../localRuntimeConnectionPool.ts | 11 +- .../attention/useAttentionSync.test.tsx | 136 ++++++++++++++++++ .../components/attention/useAttentionSync.ts | 53 +++++-- 7 files changed, 268 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 8db8aabd6..15440db38 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, protocol, safeStorage, shell } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, powerMonitor, protocol, safeStorage, shell } from "electron"; if (app.isPackaged && process.env.ADE_RUNTIME_PACKAGED === undefined) { process.env.ADE_RUNTIME_PACKAGED = "1"; @@ -6985,6 +6985,10 @@ app.whenReady().then(async () => { onOutput: handleAttentionNotchOutput, onRefreshRequested: requestAttentionNotchRefresh, }); + powerMonitor?.on?.("lock-screen", () => attentionNotchHelper?.setScreenAwake(false)); + powerMonitor?.on?.("suspend", () => attentionNotchHelper?.setScreenAwake(false)); + powerMonitor?.on?.("unlock-screen", () => attentionNotchHelper?.setScreenAwake(true)); + powerMonitor?.on?.("resume", () => attentionNotchHelper?.setScreenAwake(true)); attentionIpcBridge = registerIpc({ getCtx: () => { diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts index 940788033..9ba6004ea 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts @@ -303,7 +303,7 @@ describe("AttentionNotchHelper", () => { expect(spawnMock).not.toHaveBeenCalled(); }); - it("requests account refreshes only while the native notch is enabled", () => { + it("reconciles refresh cadence across surface, screen, and enabled state", () => { vi.useFakeTimers(); try { const child = fakeChild(); @@ -315,6 +315,7 @@ describe("AttentionNotchHelper", () => { onOutput: vi.fn(), onRefreshRequested, refreshIntervalMs: 1_000, + idleRefreshIntervalMs: 4_000, platform: "darwin", }); @@ -328,8 +329,28 @@ describe("AttentionNotchHelper", () => { soundsEnabled: false, }); child.emit("spawn"); - vi.advanceTimersByTime(2_100); + vi.advanceTimersByTime(3_999); + expect(onRefreshRequested).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onRefreshRequested).toHaveBeenCalledTimes(1); + + (child.stdout as PassThrough).write( + `${JSON.stringify({ type: "surface", displayId: 1, surface: "menu_bar" })}\n`, + ); + vi.advanceTimersByTime(999); + expect(onRefreshRequested).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(1); + expect(onRefreshRequested).toHaveBeenCalledTimes(2); + + helper.setScreenAwake(false); + vi.advanceTimersByTime(3_999); expect(onRefreshRequested).toHaveBeenCalledTimes(2); + vi.advanceTimersByTime(1); + expect(onRefreshRequested).toHaveBeenCalledTimes(3); + + helper.setScreenAwake(true); + vi.advanceTimersByTime(1_000); + expect(onRefreshRequested).toHaveBeenCalledTimes(4); helper.updateSettings({ enabled: false, @@ -340,8 +361,8 @@ describe("AttentionNotchHelper", () => { celebrationsEnabled: false, soundsEnabled: false, }); - vi.advanceTimersByTime(2_000); - expect(onRefreshRequested).toHaveBeenCalledTimes(2); + vi.advanceTimersByTime(8_000); + expect(onRefreshRequested).toHaveBeenCalledTimes(4); helper.dispose(); } finally { vi.useRealTimers(); @@ -363,6 +384,7 @@ describe("AttentionNotchHelper", () => { onOutput: vi.fn(), onRefreshRequested, refreshIntervalMs: 1_000, + idleRefreshIntervalMs: 1_000, restartDelayMs: 100, platform: "darwin", }); diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts index 4d2b4d1b8..8449da569 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts @@ -14,6 +14,7 @@ const MAX_HELPER_LINE_BYTES = 256 * 1024; const MAX_RESTART_ATTEMPTS = 3; const GRACEFUL_SHUTDOWN_MS = 500; const DEFAULT_REFRESH_INTERVAL_MS = 15_000; +const IDLE_REFRESH_INTERVAL_MS = 60_000; const MAX_PENDING_WRITES = 8; export type AttentionNotchOutput = @@ -63,6 +64,7 @@ type AttentionNotchHelperOptions = { onOutput: (output: AttentionNotchOutput) => void; onRefreshRequested?: () => void; refreshIntervalMs?: number; + idleRefreshIntervalMs?: number; restartDelayMs?: number; platform?: NodeJS.Platform; }; @@ -85,11 +87,13 @@ export class AttentionNotchHelper { private restartTimer: NodeJS.Timeout | null = null; private stableTimer: NodeJS.Timeout | null = null; private refreshTimer: NodeJS.Timeout | null = null; + private refreshTimerIntervalMs: number | null = null; private stdoutBuffer = ""; private latestSnapshot: AttentionSnapshot | null = null; private latestSettings: AttentionNotchSettings | null = null; private lastProtocolError: string | null = null; private lastSurface: AttentionNotchHealth["surface"] = null; + private screenAwake = true; private stdinBackpressured = false; private pendingWrites: AttentionNotchInput[] = []; @@ -289,6 +293,12 @@ export class AttentionNotchHelper { this.write({ type: "visibility", visible }); } + setScreenAwake(awake: boolean): void { + if (this.screenAwake === awake) return; + this.screenAwake = awake; + this.ensureRefreshTimer(); + } + reanchor(): void { this.write({ type: "reanchor" }); } @@ -388,7 +398,10 @@ export class AttentionNotchHelper { try { const parsed = JSON.parse(line) as unknown; if (isAttentionNotchOutput(parsed)) { - if (parsed.type === "surface") this.lastSurface = parsed.surface; + if (parsed.type === "surface") { + this.lastSurface = parsed.surface; + this.ensureRefreshTimer(); + } if (parsed.type === "protocol_error") this.lastProtocolError = parsed.message; this.options.onOutput(parsed); } else { @@ -413,10 +426,20 @@ export class AttentionNotchHelper { this.restartTimer.unref(); } + private activeRefreshIntervalMs(): number { + return ( + this.child + && this.childReady + && this.lastSurface != null + && this.screenAwake + ) + ? (this.options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS) + : (this.options.idleRefreshIntervalMs ?? IDLE_REFRESH_INTERVAL_MS); + } + private ensureRefreshTimer(): void { if ( - this.refreshTimer - || this.disposed + this.disposed || this.latestSettings?.enabled !== true || !this.child || !this.childReady @@ -424,6 +447,9 @@ export class AttentionNotchHelper { ) { return; } + const intervalMs = this.activeRefreshIntervalMs(); + if (this.refreshTimer && this.refreshTimerIntervalMs === intervalMs) return; + this.stopRefreshTimer(); this.refreshTimer = setInterval(() => { try { this.options.onRefreshRequested?.(); @@ -432,14 +458,15 @@ export class AttentionNotchHelper { error: error instanceof Error ? error.message : String(error), }); } - }, this.options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS); + }, intervalMs); + this.refreshTimerIntervalMs = intervalMs; this.refreshTimer.unref(); } private stopRefreshTimer(): void { - if (!this.refreshTimer) return; - clearInterval(this.refreshTimer); + if (this.refreshTimer) clearInterval(this.refreshTimer); this.refreshTimer = null; + this.refreshTimerIntervalMs = null; } } diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index d96131134..acab4c40e 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -3198,7 +3198,7 @@ describe("local runtime connection pool", () => { ); }); - it("routes machine sync calls without adding a project id", async () => { + it("routes machine sync calls without adding a project id or a timeout override", async () => { const call = vi.fn().mockResolvedValue({ mode: "standalone", connectedPeers: [], @@ -3222,12 +3222,15 @@ describe("local runtime connection pool", () => { connectedPeers: [], }); - expect(call).toHaveBeenCalledWith("sync.getStatus", { - includeTransferReadiness: true, - }); + // Callers that ask for no budget keep the runtime client's own default. + expect(call).toHaveBeenCalledWith( + "sync.getStatus", + { includeTransferReadiness: true }, + {}, + ); }); - it("routes Attention through the machine scope without adding a project id", async () => { + it("routes Attention through the machine scope with the sync-domain timeout", async () => { const call = vi.fn().mockResolvedValue({ revision: 4, items: [] }); const pool = new LocalRuntimeConnectionPool("1.2.3", { debug: vi.fn(), @@ -3245,10 +3248,14 @@ describe("local runtime connection pool", () => { since: 3, streamId: "account-stream", })).resolves.toEqual({ revision: 4, items: [] }); - expect(call).toHaveBeenCalledWith("attention.call", { - action: "getSnapshot", - args: { since: 3, streamId: "account-stream" }, - }); + expect(call).toHaveBeenCalledWith( + "attention.call", + { + action: "getSnapshot", + args: { since: 3, streamId: "account-stream" }, + }, + { timeoutMs: LOCAL_RUNTIME_SYNC_TIMEOUT_MS }, + ); }); it("keeps foreground catalog metadata authoritative while routing background actions", async () => { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 1154e7fea..7b7186da1 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -1396,16 +1396,23 @@ export class LocalRuntimeConnectionPool { async callSync( method: string, params: Record = {}, + options: { timeoutMs?: number } = {}, ): Promise { const entry = await this.connect(); - return await entry.client.call(method, params) as T; + return await entry.client.call(method, params, options) as T; } async callAttention( action: string, args: Record = {}, ): Promise { - return await this.callSync("attention.call", { action, args }); + // An Attention snapshot poll that inherits the ten-minute runtime budget + // pins the renderer on "syncing" long after the account stream has wedged. + return await this.callSync( + "attention.call", + { action, args }, + { timeoutMs: LOCAL_RUNTIME_SYNC_TIMEOUT_MS }, + ); } async callActionForRoot( diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx b/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx index b58c08203..2fc8887a1 100644 --- a/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx @@ -363,6 +363,142 @@ describe("useAttentionSync", () => { expect(attentionStore.getState().syncStatus).toBe("error"); }); + it("times out a wedged snapshot as retryable degradation and allows a later refresh", async () => { + vi.useFakeTimers(); + const getSnapshot = vi.fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce({ + contractVersion: ATTENTION_CONTRACT_VERSION, + scope: "machine", + revision: 1, + generatedAt: "2026-07-28T14:01:00.000Z", + items: [], + tombstones: [], + } satisfies AttentionSnapshot); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot, + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + }, + }); + + const timedOutRefresh = refreshAttentionSnapshot(); + expect(attentionStore.getState().syncStatus).toBe("syncing"); + + await vi.advanceTimersByTimeAsync(45_000); + await timedOutRefresh; + + expect(attentionStore.getState()).toMatchObject({ + syncStatus: "error", + syncError: "Attention took too long to respond. Retry to restore live updates.", + availability: { + state: "degraded", + recovery: "retry", + }, + }); + + await refreshAttentionSnapshot(); + + expect(getSnapshot).toHaveBeenCalledTimes(2); + expect(attentionStore.getState()).toMatchObject({ + syncStatus: "ready", + syncError: null, + revision: 1, + }); + }); + + it("uses relaxed hidden presence cadence and sends immediately when visible again", async () => { + publishAccountStatus({ + signedIn: true, + userId: "user-presence-cadence", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + }); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + const addEventListenerSpy = vi.spyOn(document, "addEventListener"); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + const reportPresence = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async (): Promise => ({ + contractVersion: ATTENTION_CONTRACT_VERSION, + scope: "account", + revision: 0, + generatedAt: "2026-07-28T14:01:00.000Z", + items: [], + tombstones: [], + })), + acknowledge: vi.fn(), + reportPresence, + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + }, + }); + + render(); + await waitFor(() => { + expect(reportPresence).toHaveBeenCalled(); + expect(setTimeoutSpy.mock.calls.some((call) => call[1] === 30_000)).toBe(true); + const visibilityChangeCalls = addEventListenerSpy.mock.calls + .filter((call) => call[0] === "visibilitychange"); + expect(visibilityChangeCalls.length).toBeGreaterThanOrEqual(2); + }); + const visibilityChangeListener = addEventListenerSpy.mock.calls + .filter((call) => call[0] === "visibilitychange")[1]?.[1]; + expect(typeof visibilityChangeListener).toBe("function"); + const mountPresenceCount = reportPresence.mock.calls.length; + const hiddenSchedulesBefore = setTimeoutSpy.mock.calls + .filter((call) => call[1] === 120_000) + .length; + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + act(() => { + (visibilityChangeListener as EventListener)(new Event("visibilitychange")); + }); + expect(reportPresence).toHaveBeenCalledTimes(mountPresenceCount); + expect(setTimeoutSpy.mock.calls.filter((call) => call[1] === 120_000)) + .toHaveLength(hiddenSchedulesBefore + 1); + + const visibleSchedulesBefore = setTimeoutSpy.mock.calls + .filter((call) => call[1] === 30_000) + .length; + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + act(() => { + (visibilityChangeListener as EventListener)(new Event("visibilitychange")); + }); + await waitFor(() => { + expect(reportPresence).toHaveBeenCalledTimes(mountPresenceCount + 1); + }); + expect(setTimeoutSpy.mock.calls.filter((call) => call[1] === 30_000)) + .toHaveLength(visibleSchedulesBefore + 1); + }); + it("keeps an in-flight account A preference fetch out of account B's notch stream", async () => { publishAccountStatus({ signedIn: true, diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts index 6e70cb705..b13d88c7c 100644 --- a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts @@ -23,6 +23,10 @@ export { attentionNotchSettingsFromPreferences } from "./attentionNotchLocalSett const POLL_INTERVAL_MS = 15_000; const PRESENCE_INTERVAL_MS = 30_000; +const HIDDEN_PRESENCE_INTERVAL_MS = 120_000; +// This backstop sits above the main-process budget so normal host failures +// retain their real error and only a wedged IPC layer replaces it. +const ATTENTION_SNAPSHOT_TIMEOUT_MS = 45_000; const NOTCH_SETTINGS_REFRESH_MS = 60_000; const MAX_VISIBLE_PRESENCE_ITEMS = 64; @@ -141,11 +145,19 @@ export async function refreshAttentionSnapshot(): Promise { } attentionStore.getState().setSyncStatus("syncing"); - const promise = api - .getSnapshot( + let timeoutId: ReturnType | null = null; + const snapshotPromise = Promise.resolve().then(() => api.getSnapshot( attentionStore.getState().revision, attentionStore.getState().streamId, - ) + )); + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + reject(new Error( + "Attention took too long to respond. Retry to restore live updates.", + )); + }, ATTENTION_SNAPSHOT_TIMEOUT_MS); + }); + const promise = Promise.race([snapshotPromise, timeoutPromise]) .then((snapshot) => { if ( generation !== attentionAccountGeneration @@ -162,6 +174,10 @@ export async function refreshAttentionSnapshot(): Promise { attentionStore.getState().setSyncStatus("error", errorMessage(error)); }) .finally(() => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } if (refreshPromise?.promise === promise) refreshPromise = null; }); refreshPromise = { generation, promise }; @@ -436,11 +452,6 @@ export function useAttentionSync(routeSurfaceVisible: boolean): void { const onVisibilityChange = () => { foregroundRef.current = document.visibilityState === "visible" && document.hasFocus(); if (foregroundRef.current) void refreshAttentionSnapshot(); - void reportPresence( - ambientSurfaceVisibleRef.current, - visibleItemIdsRef.current, - foregroundRef.current, - ).catch(() => {}); }; document.addEventListener("visibilitychange", onVisibilityChange); return () => { @@ -463,8 +474,28 @@ export function useAttentionSync(routeSurfaceVisible: boolean): void { foregroundRef.current, ).catch(() => {}); }; + let timer: number | null = null; + const schedule = () => { + if (timer !== null) window.clearTimeout(timer); + const delay = document.visibilityState === "visible" + ? PRESENCE_INTERVAL_MS + : HIDDEN_PRESENCE_INTERVAL_MS; + timer = window.setTimeout(() => { + timer = null; + send(); + schedule(); + }, delay); + }; send(); - const interval = window.setInterval(send, PRESENCE_INTERVAL_MS); + schedule(); + const onVisibilityChange = () => { + if (timer !== null) { + window.clearTimeout(timer); + timer = null; + } + if (document.visibilityState === "visible") send(); + schedule(); + }; const onFocus = () => { foregroundRef.current = true; send(); @@ -473,10 +504,12 @@ export function useAttentionSync(routeSurfaceVisible: boolean): void { foregroundRef.current = false; send(); }; + document.addEventListener("visibilitychange", onVisibilityChange); window.addEventListener("focus", onFocus); window.addEventListener("blur", onBlur); return () => { - window.clearInterval(interval); + if (timer !== null) window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisibilityChange); window.removeEventListener("focus", onFocus); window.removeEventListener("blur", onBlur); }; From ac040068521c87e090b7ccb64d2e35a63527470b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:49:57 -0400 Subject: [PATCH 3/4] fix(work): close presence and lane-cadence gaps found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six real defects, four of them in the new code: - A remount re-brightened a machine that was already dimmed. Leaving Work and coming back tears the shared runtime down, taking its drop records with it while the store slice survives, so the next snapshot derived a fresh drop and held the machine live for another floor — group re-expanded, actions re-enabled. The verdict now survives: a dimmed machine is only ever re-brightened by becoming eligible again, and its retention deadline is re-anchored to its last successful read. - The catch-up lane read fired every tick instead of once. `session.list` does not filter on lane status while `lane.list` asks for `includeArchived: false`, so a chat on an archived lane is permanently unresolvable and demanded a fresh `includeStatus` read forever — more expensive than before the cadence existed. Lane ids a completed read did not explain are now remembered until the next one, and both read paths share the helper that owns the rule. - Removal on "the repository is gone" believed a folder-name mismatch. The scope's origin is re-resolved from the bound machine and can be transiently null, and `repoMatchFor` will say "missing" off a name alone — so a healthy machine's rows could be deleted while the bound machine blipped. Removal now requires an origin to prove it by. - A connected machine whose repository could not be re-proven stayed bright and was never read again: eligible for display, ineligible for refresh. It now dims on the same floor. It is still not removed — absence of proof is not proof of absence — but it stops claiming to be live. - The lane cadence could be stamped by a read that resolved after a scope change, suppressing the new scope's first lane read. - The Attention backstop was below the budget it was meant to clear: 15s relay request plus one 401 retry plus the 30s local fallback is 60s, so a 45s race could discard a slow-but-successful snapshot. Raised to 75s. Structural, from the same review: the hold/dim rule is one deadline instead of a double negative plus a matching ternary; `MachineConnectivity` carries the machine option rather than a nullable copy of one field, and the eligible list is derived from it instead of re-deriving; the machine marker moved out of the 2.3k-line session list; the offline-has-no-live-work rule is one predicate instead of two inverted copies. `resume` no longer claims the screen is awake while it is still locked, and a respawned notch helper no longer inherits the previous child's surface. The presence-cadence test now advances the clock instead of asserting that a timer was scheduled, so it would catch a chain that fires once and never re-arms. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/main/main.ts | 27 ++- .../attention/attentionNotchHelper.test.ts | 14 +- .../attention/attentionNotchHelper.ts | 8 +- .../attention/useAttentionSync.test.tsx | 85 ++++--- .../components/attention/useAttentionSync.ts | 14 +- .../terminals/ForeignLaneContextMenu.tsx | 2 +- .../terminals/LaneMachineMarker.tsx | 54 +++++ .../terminals/SessionListPane.test.tsx | 2 +- .../components/terminals/SessionListPane.tsx | 79 ++----- .../renderer/state/crossMachineLanes.test.ts | 113 +++++++++- .../src/renderer/state/crossMachineLanes.ts | 207 ++++++++++++------ 11 files changed, 414 insertions(+), 191 deletions(-) create mode 100644 apps/desktop/src/renderer/components/terminals/LaneMachineMarker.tsx diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 15440db38..85e48b318 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -6985,10 +6985,29 @@ app.whenReady().then(async () => { onOutput: handleAttentionNotchOutput, onRefreshRequested: requestAttentionNotchRefresh, }); - powerMonitor?.on?.("lock-screen", () => attentionNotchHelper?.setScreenAwake(false)); - powerMonitor?.on?.("suspend", () => attentionNotchHelper?.setScreenAwake(false)); - powerMonitor?.on?.("unlock-screen", () => attentionNotchHelper?.setScreenAwake(true)); - powerMonitor?.on?.("resume", () => attentionNotchHelper?.setScreenAwake(true)); + // Sleep does not always lock the machine, so resume must clear suspension + // without overriding the independent lock state. + let notchScreenLocked = false; + let notchSystemSuspended = false; + const syncNotchScreenState = () => { + attentionNotchHelper?.setScreenAwake(!notchScreenLocked && !notchSystemSuspended); + }; + powerMonitor?.on?.("lock-screen", () => { + notchScreenLocked = true; + syncNotchScreenState(); + }); + powerMonitor?.on?.("unlock-screen", () => { + notchScreenLocked = false; + syncNotchScreenState(); + }); + powerMonitor?.on?.("suspend", () => { + notchSystemSuspended = true; + syncNotchScreenState(); + }); + powerMonitor?.on?.("resume", () => { + notchSystemSuspended = false; + syncNotchScreenState(); + }); attentionIpcBridge = registerIpc({ getCtx: () => { diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts index 9ba6004ea..7f9ea7d96 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts @@ -369,7 +369,7 @@ describe("AttentionNotchHelper", () => { } }); - it("restarts after a spawn error without leaving a refresh timer running", () => { + it("restarts after a spawn error without retaining the previous surface or refresh timer", () => { vi.useFakeTimers(); try { const failedChild = fakeChild(); @@ -384,7 +384,7 @@ describe("AttentionNotchHelper", () => { onOutput: vi.fn(), onRefreshRequested, refreshIntervalMs: 1_000, - idleRefreshIntervalMs: 1_000, + idleRefreshIntervalMs: 4_000, restartDelayMs: 100, platform: "darwin", }); @@ -407,8 +407,16 @@ describe("AttentionNotchHelper", () => { celebrationsEnabled: true, soundsEnabled: false, }); + failedChild.emit("spawn"); + (failedChild.stdout as PassThrough).write( + `${JSON.stringify({ type: "surface", displayId: 1, surface: "menu_bar" })}\n`, + ); failedChild.emit("error", new Error("spawn ENOEXEC")); failedChild.emit("close", -2, null); + expect(helper.getHealth()).toMatchObject({ + state: "starting", + surface: null, + }); vi.advanceTimersByTime(1_000); expect(onRefreshRequested).not.toHaveBeenCalled(); @@ -416,6 +424,8 @@ describe("AttentionNotchHelper", () => { restartedChild.emit("spawn"); vi.advanceTimersByTime(1_000); + expect(onRefreshRequested).not.toHaveBeenCalled(); + vi.advanceTimersByTime(3_000); expect(onRefreshRequested).toHaveBeenCalledOnce(); helper.dispose(); } finally { diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts index 8449da569..9b3ae4c6a 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts @@ -160,6 +160,7 @@ export class AttentionNotchHelper { }); child.once("close", (code, signal) => { this.childReady = false; + this.lastSurface = null; this.stopRefreshTimer(); if (this.stableTimer) { clearTimeout(this.stableTimer); @@ -427,12 +428,7 @@ export class AttentionNotchHelper { } private activeRefreshIntervalMs(): number { - return ( - this.child - && this.childReady - && this.lastSurface != null - && this.screenAwake - ) + return (this.lastSurface != null && this.screenAwake) ? (this.options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS) : (this.options.idleRefreshIntervalMs ?? IDLE_REFRESH_INTERVAL_MS); } diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx b/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx index 2fc8887a1..b730783b6 100644 --- a/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx @@ -101,6 +101,14 @@ function Harness({ surfaceVisible = true }: { surfaceVisible?: boolean }) { return null; } +function setDocumentVisibility(state: "visible" | "hidden"): void { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + afterEach(() => { cleanup(); vi.restoreAllMocks(); @@ -393,7 +401,7 @@ describe("useAttentionSync", () => { const timedOutRefresh = refreshAttentionSnapshot(); expect(attentionStore.getState().syncStatus).toBe("syncing"); - await vi.advanceTimersByTimeAsync(45_000); + await vi.advanceTimersByTimeAsync(75_000); await timedOutRefresh; expect(attentionStore.getState()).toMatchObject({ @@ -416,7 +424,9 @@ describe("useAttentionSync", () => { }); it("uses relaxed hidden presence cadence and sends immediately when visible again", async () => { - publishAccountStatus({ + vi.useFakeTimers(); + setDocumentVisibility("visible"); + const signedInStatus = { signedIn: true, userId: "user-presence-cadence", email: null, @@ -424,13 +434,7 @@ describe("useAttentionSync", () => { expiresAt: null, provider: null, imageUrl: null, - }); - Object.defineProperty(document, "visibilityState", { - configurable: true, - value: "visible", - }); - const addEventListenerSpy = vi.spyOn(document, "addEventListener"); - const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + } satisfies Parameters[0]; const reportPresence = vi.fn(async () => undefined); Object.defineProperty(window, "ade", { configurable: true, @@ -451,52 +455,45 @@ describe("useAttentionSync", () => { getPreferences: vi.fn(), putPreferences: vi.fn(), }, + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => signedInStatus), + }, }, }); + publishAccountStatus(signedInStatus); + // A presence send resolves the device identity before it POSTs, so the + // clock has to advance with microtasks flushed between timers. + const advancePresenceTimers = async (durationMs: number) => { + await act(async () => { + await vi.advanceTimersByTimeAsync(durationMs); + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + }); + }; render(); - await waitFor(() => { - expect(reportPresence).toHaveBeenCalled(); - expect(setTimeoutSpy.mock.calls.some((call) => call[1] === 30_000)).toBe(true); - const visibilityChangeCalls = addEventListenerSpy.mock.calls - .filter((call) => call[0] === "visibilitychange"); - expect(visibilityChangeCalls.length).toBeGreaterThanOrEqual(2); - }); - const visibilityChangeListener = addEventListenerSpy.mock.calls - .filter((call) => call[0] === "visibilitychange")[1]?.[1]; - expect(typeof visibilityChangeListener).toBe("function"); + await advancePresenceTimers(0); const mountPresenceCount = reportPresence.mock.calls.length; - const hiddenSchedulesBefore = setTimeoutSpy.mock.calls - .filter((call) => call[1] === 120_000) - .length; + expect(mountPresenceCount).toBeGreaterThan(0); + + await advancePresenceTimers(30_000); + expect(reportPresence).toHaveBeenCalledTimes(mountPresenceCount + 1); + await advancePresenceTimers(30_000); + expect(reportPresence).toHaveBeenCalledTimes(mountPresenceCount + 2); - Object.defineProperty(document, "visibilityState", { - configurable: true, - value: "hidden", - }); act(() => { - (visibilityChangeListener as EventListener)(new Event("visibilitychange")); + setDocumentVisibility("hidden"); }); - expect(reportPresence).toHaveBeenCalledTimes(mountPresenceCount); - expect(setTimeoutSpy.mock.calls.filter((call) => call[1] === 120_000)) - .toHaveLength(hiddenSchedulesBefore + 1); + await advancePresenceTimers(30_000); + expect(reportPresence).toHaveBeenCalledTimes(mountPresenceCount + 2); + await advancePresenceTimers(90_000); + expect(reportPresence).toHaveBeenCalledTimes(mountPresenceCount + 3); - const visibleSchedulesBefore = setTimeoutSpy.mock.calls - .filter((call) => call[1] === 30_000) - .length; - - Object.defineProperty(document, "visibilityState", { - configurable: true, - value: "visible", - }); act(() => { - (visibilityChangeListener as EventListener)(new Event("visibilitychange")); - }); - await waitFor(() => { - expect(reportPresence).toHaveBeenCalledTimes(mountPresenceCount + 1); + setDocumentVisibility("visible"); }); - expect(setTimeoutSpy.mock.calls.filter((call) => call[1] === 30_000)) - .toHaveLength(visibleSchedulesBefore + 1); + await advancePresenceTimers(0); + expect(reportPresence).toHaveBeenCalledTimes(mountPresenceCount + 4); }); it("keeps an in-flight account A preference fetch out of account B's notch stream", async () => { diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts index b13d88c7c..c044818f1 100644 --- a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts @@ -24,9 +24,9 @@ export { attentionNotchSettingsFromPreferences } from "./attentionNotchLocalSett const POLL_INTERVAL_MS = 15_000; const PRESENCE_INTERVAL_MS = 30_000; const HIDDEN_PRESENCE_INTERVAL_MS = 120_000; -// This backstop sits above the main-process budget so normal host failures -// retain their real error and only a wedged IPC layer replaces it. -const ATTENTION_SNAPSHOT_TIMEOUT_MS = 45_000; +// This backstop must clear a 15s relay request, one 401 retry, and the 30s +// local-runtime fallback so legitimate host failures retain their real error. +const ATTENTION_SNAPSHOT_TIMEOUT_MS = 75_000; const NOTCH_SETTINGS_REFRESH_MS = 60_000; const MAX_VISIBLE_PRESENCE_ITEMS = 64; @@ -489,10 +489,10 @@ export function useAttentionSync(routeSurfaceVisible: boolean): void { send(); schedule(); const onVisibilityChange = () => { - if (timer !== null) { - window.clearTimeout(timer); - timer = null; - } + // Coming back reports at once: presence is how other devices learn this + // machine is being watched, and a 120s-stale "hidden" claim right as the + // user returns is the one case that misleads. Going hidden waits — `blur` + // has already reported the foreground change. if (document.visibilityState === "visible") send(); schedule(); }; diff --git a/apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx index 3914f46a8..477194d71 100644 --- a/apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx @@ -13,7 +13,7 @@ import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; type ForeignLaneContextMenuProps = { lane: LaneSummary; machineName: string; - /** Live, not captured: every action below runs on the owning machine. */ + /** False disables every action below: they all run on the owning machine. */ online: boolean; x: number; y: number; diff --git a/apps/desktop/src/renderer/components/terminals/LaneMachineMarker.tsx b/apps/desktop/src/renderer/components/terminals/LaneMachineMarker.tsx new file mode 100644 index 000000000..30f037748 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LaneMachineMarker.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { DesktopTower } from "@phosphor-icons/react"; +import type { CrossMachineLaneMarker } from "../../state/crossMachineLanes"; +import { SmartTooltip } from "../ui/SmartTooltip"; +import { cn } from "../ui/cn"; + +/** + * Adaptive machine marker on a lane header. + * + * Rendered ONLY for lanes that are not on the machine you're sitting at — the + * common single-machine case pays nothing. Default form is a bare monochrome + * glyph; the name is promoted into the row when a glyph alone would be + * ambiguous (the machine is offline, two or more foreign machines on screen, or + * the branch also exists elsewhere). The lane accent owns the color channel, so + * this stays monochrome: a tint here would read as a second lane color. + * + * An unreachable machine keeps its rows, so this has a dimmed form — and it is + * the only thing on the row that says why the group has gone quiet. + */ +export function LaneMachineMarker({ marker }: { marker: CrossMachineLaneMarker }) { + return ( + + + + {marker.mode === "name" ? {marker.machineName} : null} + + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index 761a01b2f..6cb1590fc 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -1216,7 +1216,7 @@ describe("SessionListPane", () => { const header = screen.getByText("Elsewhere Lane").closest( ".ade-lane-group-header", )!; - expect(header.closest(".opacity-55")).not.toBeNull(); + expect(header.closest("[data-dimmed]")).not.toBeNull(); const marker = document.querySelector("[data-machine-marker-mode]")!; expect(marker.getAttribute("data-machine-online")).toBe("false"); // A glyph cannot say "offline", so the machine name is always spelled out. diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 378ce3f76..3f3385b04 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ArrowClockwise, CaretDown, CaretRight, CircleNotch, Desktop, DesktopTower, Funnel, MagnifyingGlass, Moon, Plus, PushPin, Square, Terminal, Trash, WarningCircle, X } from "@phosphor-icons/react"; +import { ArrowClockwise, CaretDown, CaretRight, CircleNotch, Desktop, Funnel, MagnifyingGlass, Moon, Plus, PushPin, Square, Terminal, Trash, WarningCircle, X } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; import { BranchIcon, LaneIcon } from "../ui/vcsIcons"; import type { LaneSummary, OpenProjectBinding, PrSummary, TerminalSessionSummary } from "../../../shared/types"; @@ -15,9 +15,9 @@ import { import { useAppStore } from "../../state/appStore"; import { useCrossMachineLaneUnion, - type CrossMachineLaneMarker, type CrossMachineLaneRow, } from "../../state/crossMachineLanes"; +import { LaneMachineMarker } from "./LaneMachineMarker"; import { SessionCard } from "./SessionCard"; import { ToolLogo } from "./ToolLogos"; import { LaneCombobox } from "./LaneCombobox"; @@ -108,6 +108,19 @@ function bucketHandoffJobsByTime(jobs: HandoffLaunchJob[]) { return { today, yesterday, older }; } +/** + * Whether a foreign lane has work actually in flight. An offline machine's chats + * can still read as running — that is only the last thing it reported — so both + * the collapse default and the auto-collapse effect must ask this, not the + * partition alone, or expanding a dropped machine's group slams it shut again. + */ +function foreignRowHasLiveWork( + row: CrossMachineLaneRow, + active: readonly TerminalSessionSummary[], +): boolean { + return row.online && active.length > 0; +} + function partitionQuietSessions(sessions: readonly TerminalSessionSummary[]): { active: TerminalSessionSummary[]; snoozed: TerminalSessionSummary[]; @@ -397,6 +410,7 @@ function StickyGroupHeader({ onLayoutAnimationStart={() => setSliding(true)} onLayoutAnimationComplete={() => setSliding(false)} className={cn("relative", !isLane && "mt-0.5 first:mt-0", dimmed && "opacity-55")} + data-dimmed={dimmed ? "true" : undefined} > {dropIndicatorEdge ? (
void } ); } -/** - * Adaptive machine marker on a lane header. - * - * Rendered ONLY for lanes that are not on the machine you're sitting at — the - * common single-machine case pays nothing. Default form is a bare monochrome - * glyph; the name is promoted into the row when a glyph alone would be - * ambiguous (the machine is offline, two or more foreign machines on screen, or - * the branch also exists elsewhere). The lane accent owns the color channel, so - * this stays monochrome: a tint here would read as a second lane color. - * - * An unreachable machine keeps its rows, so this has a dimmed form — and it is - * the only thing on the row that says why the group has gone quiet. - */ -function LaneMachineMarker({ marker }: { marker: CrossMachineLaneMarker }) { - return ( - - - - {marker.mode === "name" ? {marker.machineName} : null} - - - ); -} - export const SessionListPane = React.memo(function SessionListPane({ lanes, runningFiltered, @@ -1490,12 +1455,9 @@ export const SessionListPane = React.memo(function SessionListPane({ const foreignRow = foreignRows.find( (row) => `${row.machineId}:${row.lane.id}` === laneId, ); - // An offline machine's chats can look "active" — that is just the last - // thing it reported. Discarding the expansion on that evidence would slam - // the group shut the moment the user opened it to read them. if ( - foreignRow?.online - && partitionQuietSessions(foreignRow.sessions).active.length > 0 + foreignRow + && foreignRowHasLiveWork(foreignRow, partitionQuietSessions(foreignRow.sessions).active) ) { toggleWorkSectionCollapsed(marker, { preserveDeeplink: true }); } @@ -1779,10 +1741,9 @@ export const SessionListPane = React.memo(function SessionListPane({ const compositeLaneId = `${row.machineId}:${row.lane.id}`; const marker = markersByLaneId.get(compositeLaneId) ?? null; const quiet = partitionQuietSessions(row.sessions); - // An offline machine's chats cannot be running, whatever they last - // reported, so the group folds shut like a quiet one: retained and + // An offline machine's group folds shut like a quiet one: retained and // inspectable, not presented as live work. - const laneQuiet = !row.online || quiet.active.length === 0; + const laneQuiet = !foreignRowHasLiveWork(row, quiet.active); const laneOpenMarker = `lane-open:${compositeLaneId}`; const collapsed = laneQuiet ? !workCollapsedSectionIds.includes(laneOpenMarker) diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts index c98d1c0bc..1ae4e0698 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts @@ -968,6 +968,13 @@ describe("cross-machine refresh scheduling", () => { await vi.advanceTimersByTimeAsync(10_500); expect(requests.filter((entry) => entry.domain === "lane")).toHaveLength(2); + // That lane read did not explain it — `session.list` does not filter on lane + // status while `lane.list` excludes archived lanes, so a chat on an archived + // lane is permanently unresolvable. Asking again every tick would cost more + // than the cadence this test exists to prove. + await vi.advanceTimersByTimeAsync(21_000); + expect(requests.filter((entry) => entry.domain === "lane")).toHaveLength(2); + stop(); }); @@ -1066,11 +1073,14 @@ describe("believing a drop before a machine dims", () => { pushState: (state: string) => void; } { let emit: ((next: unknown) => void) | null = null; + // The snapshot read reports the machine's CURRENT state, the way the real + // one does — a remount must not be told the machine is back. + let latest: unknown = snapshot("connected"); window.ade = { remoteRuntime: { // Reads never resolve: this suite is about reachability, not lane data. callAction: vi.fn(() => new Promise(() => {})), - getConnectionSnapshot: vi.fn(async () => snapshot("connected")), + getConnectionSnapshot: vi.fn(async () => latest), onConnectionSnapshotChanged: vi.fn((listener: (next: unknown) => void) => { emit = listener; return () => { emit = null; }; @@ -1086,8 +1096,8 @@ describe("believing a drop before a machine dims", () => { }); return { stop, - push: (next) => emit?.(next), - pushState: (state) => emit?.(snapshot(state)), + push: (next) => { latest = next; emit?.(next); }, + pushState: (state) => { latest = snapshot(state); emit?.(latest); }, }; } @@ -1214,6 +1224,103 @@ describe("believing a drop before a machine dims", () => { stop(); }); + it("keeps a dimmed machine dimmed across a remount", async () => { + vi.useFakeTimers(); + const { stop, pushState } = await startSeeded(); + + pushState("connecting"); + pushState("error"); + await vi.advanceTimersByTimeAsync(46_000); + expect(isOnline()).toBe(false); + + // Leaving Work and coming back tears the shared runtime down, taking its + // drop records with it while the store slice survives. Re-deriving a fresh + // drop would restart the floor and flash the machine back to live, with its + // group re-expanded and every action on it re-enabled. + stop(); + const restarted = startCrossMachineLaneSync({ + scopeKey: "local:/repo-a", + repoDisplayName: "Repo A", + repoOriginUrl: "git@github.com:acme/repo-a.git", + boundTargetId: null, + boundProjectId: null, + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(500); + expect(isOnline()).toBe(false); + + restarted(); + }); + + it("dims a connected machine whose repository it can no longer prove", async () => { + vi.useFakeTimers(); + const { stop, push } = await startSeeded(); + + // Connected, folder name still matches, but the origin is gone from its + // project record — so identity is `unknown`, not `missing`. It must not be + // deleted, and it must not stay bright either: nothing is reading it. + push({ + connections: [{ + state: "connected", + target: CONNECTED_TARGET, + projects: [{ ...PROJECTS[0], gitOriginUrl: null }], + }], + connectedCount: 1, + }); + await vi.advanceTimersByTimeAsync(44_000); + expect(isOnline()).toBe(true); + await vi.advanceTimersByTimeAsync(2_000); + expect(isOnline()).toBe(false); + expect(entry()).toBeDefined(); + + stop(); + }); + + it("keeps a machine whose repository cannot be proven absent without an origin", async () => { + vi.useFakeTimers(); + const snapshots: { emit: ((next: unknown) => void) | null } = { emit: null }; + window.ade = { + remoteRuntime: { + callAction: vi.fn(() => new Promise(() => {})), + getConnectionSnapshot: vi.fn(async () => snapshot("connected")), + onConnectionSnapshotChanged: vi.fn((listener: (next: unknown) => void) => { + snapshots.emit = listener; + return () => { snapshots.emit = null; }; + }), + }, + } as unknown as typeof window.ade; + // No origin for this scope, so a folder-name mismatch is the only evidence + // available — and a name is not an identity. Deleting rows on that is not + // recoverable, so the machine is held instead. + const stop = startCrossMachineLaneSync({ + scopeKey: "local:/repo-a", + repoDisplayName: "Repo A", + repoOriginUrl: null, + boundTargetId: null, + boundProjectId: null, + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(400); + seedMachine(); + + snapshots.emit?.({ + connections: [{ + state: "connected", + target: CONNECTED_TARGET, + projects: [{ + projectId: "project-z", + rootPath: "/elsewhere/some-other-name", + displayName: "Some Other Name", + gitOriginUrl: null, + }], + }], + connectedCount: 1, + }); + expect(entry()).toBeDefined(); + + stop(); + }); + it("does not let a read that was in flight across a disconnect resurrect the machine", async () => { vi.useFakeTimers(); const pendingReads: Array<(value: { result: unknown }) => void> = []; diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.ts b/apps/desktop/src/renderer/state/crossMachineLanes.ts index 50ad1fbf9..e3c83f5fd 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.ts @@ -51,7 +51,6 @@ import { import { deriveLaneMachineOptions, type LaneMachineOption, - type LaneMachineRepoMatch, } from "../components/lanes/laneMachines"; import { rootAppStoreApi, @@ -641,6 +640,8 @@ type SyncRuntime = { graceTimer: ReturnType | null; /** Last `lane.list` read per machine, keyed the same way the store is. */ laneReadAtMsByMachineId: Map; + /** Lane ids a completed lane read did not explain, so we stop re-asking. */ + unresolvedLaneIdsByMachineId: Map>; }; /** @@ -682,6 +683,7 @@ const runtime: SyncRuntime = { dropsByMachineId: new Map(), graceTimer: null, laneReadAtMsByMachineId: new Map(), + unresolvedLaneIdsByMachineId: new Map(), }; /** @@ -784,12 +786,21 @@ function isMachineEligibleNow(machineId: string): boolean { * Whether this tick should re-read a machine's lanes as well as its chats. * First read always does; after that the lane list has its own slow cadence. */ -function shouldReadLanes(machineId: string, nowMs: number): boolean { +function shouldReadLanes(machineId: string): boolean { const readAtMs = runtime.laneReadAtMsByMachineId.get(machineId); - return readAtMs == null || nowMs - readAtMs >= FOREIGN_LANE_REFRESH_MS; + return readAtMs == null || Date.now() - readAtMs >= FOREIGN_LANE_REFRESH_MS; } -/** Lanes referenced by chats we can see but have no lane row for. */ +/** + * A chat on a lane we have no row for, that a lane read has not already failed + * to explain. + * + * The second half is load-bearing. `session.list` does not filter on lane + * status while `lane.list` asks for `includeArchived: false`, so a chat on an + * archived lane is permanently unresolvable — and without remembering that, it + * would demand a fresh lane read on every single tick, which is exactly the cost + * this cadence exists to remove. + */ function hasUnknownLaneReference( machineId: string, sessions: readonly TerminalSessionSummary[], @@ -798,7 +809,41 @@ function hasUnknownLaneReference( (rootAppStoreApi.getState().crossMachineLanesByMachineId[machineId]?.lanes ?? []) .map((lane) => lane.id), ); - return sessions.some((session) => session.laneId && !known.has(session.laneId)); + const unexplained = runtime.unresolvedLaneIdsByMachineId.get(machineId); + return sessions.some((session) => + session.laneId && !known.has(session.laneId) && !unexplained?.has(session.laneId)); +} + +/** + * Applies the lane cadence to one machine's read and records what it settled. + * + * Both read paths share this because the invariant they must not drift on is + * "stamp the cadence only when lanes were actually read" — get that wrong on one + * side and that machine pays the full `includeStatus` cost on every tick forever. + */ +async function resolveLaneCadence( + machineId: string, + prefetched: LaneSummary[] | null, + sessions: readonly TerminalSessionSummary[], + generation: number, + catchUp: () => Promise, +): Promise { + const lanes = prefetched + ?? (hasUnknownLaneReference(machineId, sessions) ? await catchUp() : null); + if (!lanes) return null; + // The catch-up read awaits the network, and a scope change in that window has + // already cleared these maps for the new scope. Re-populating them here would + // suppress that scope's first lane read for a full cadence. + if (generation !== runtime.generation) return null; + runtime.laneReadAtMsByMachineId.set(machineId, Date.now()); + const known = new Set(lanes.map((lane) => lane.id)); + const unexplained = new Set(); + for (const session of sessions) { + if (session.laneId && !known.has(session.laneId)) unexplained.add(session.laneId); + } + if (unexplained.size > 0) runtime.unresolvedLaneIdsByMachineId.set(machineId, unexplained); + else runtime.unresolvedLaneIdsByMachineId.delete(machineId); + return lanes; } async function readMachine( @@ -823,9 +868,8 @@ async function readMachine( `lane.list on ${machineName}`, ); try { - const wantLanes = shouldReadLanes(machineId, Date.now()); const [laneResult, sessionResult] = await Promise.all([ - wantLanes ? readLanes() : null, + shouldReadLanes(machineId) ? readLanes() : null, withTimeout( callAction(targetId, projectId, { domain: "session", @@ -843,13 +887,14 @@ async function readMachine( // A chat is rendered under its lane, so a chat launched on a lane this // machine has never reported would be invisible until the slow lane cadence // came round. Seeing one is the signal to pay for the lane read now. - let lanes = laneResult ? decodeForeignLanes(laneResult.result) : null; - if (!lanes && hasUnknownLaneReference(machineId, sessions)) { - const catchUp = await readLanes(); - if (generation !== runtime.generation) return; - lanes = decodeForeignLanes(catchUp.result); - } - if (lanes) runtime.laneReadAtMsByMachineId.set(machineId, Date.now()); + const lanes = await resolveLaneCadence( + machineId, + laneResult ? decodeForeignLanes(laneResult.result) : null, + sessions, + generation, + async () => decodeForeignLanes((await readLanes()).result), + ); + if (generation !== runtime.generation) return; store.mergeCrossMachineLanes({ machineId, machineName, @@ -895,9 +940,8 @@ async function readThisMachine( "lane.list on This Mac", ); try { - const wantLanes = shouldReadLanes(THIS_MACHINE_ID, Date.now()); const [laneResult, sessions] = await Promise.all([ - wantLanes ? readLanes() : null, + shouldReadLanes(THIS_MACHINE_ID) ? readLanes() : null, withTimeout( window.ade.sessions.list( { limit: FOREIGN_SESSION_LIMIT }, @@ -908,12 +952,14 @@ async function readThisMachine( ), ]); if (generation !== runtime.generation) return; - let lanes = laneResult; - if (!lanes && hasUnknownLaneReference(THIS_MACHINE_ID, sessions)) { - lanes = await readLanes(); - if (generation !== runtime.generation) return; - } - if (lanes) runtime.laneReadAtMsByMachineId.set(THIS_MACHINE_ID, Date.now()); + const lanes = await resolveLaneCadence( + THIS_MACHINE_ID, + laneResult, + sessions, + generation, + readLanes, + ); + if (generation !== runtime.generation) return; store.mergeCrossMachineLanes({ machineId: THIS_MACHINE_ID, machineName: THIS_MACHINE_NAME, @@ -1063,15 +1109,22 @@ function isEligibleMachineOption(option: LaneMachineOption): boolean { } function resolveEligibleMachines(): LaneMachineOption[] { - return resolveMachineOptions().filter(isEligibleMachineOption); + const eligible: LaneMachineOption[] = []; + for (const machine of resolveMachineConnectivity().values()) { + if (machine.eligible && machine.option) eligible.push(machine.option); + } + return eligible; } -/** What the newest snapshot says about one machine, whether connected or not. */ +/** + * What the newest snapshot says about one machine. A machine that is not in the + * snapshot at all has no entry here — absence in the map IS that fact, which is + * why there is no "gone" state to represent. + */ type MachineConnectivity = { - /** `null` when the machine is not in the connection snapshot at all. */ - state: RemoteRuntimeConnectionState | null; - /** `null` unless the machine is connected — the match needs its project list. */ - repoMatch: LaneMachineRepoMatch | null; + state: RemoteRuntimeConnectionState; + /** Present only for connected machines: the match needs their project list. */ + option: LaneMachineOption | null; eligible: boolean; }; @@ -1080,7 +1133,7 @@ function resolveMachineConnectivity(): Map { for (const connection of runtime.connections) { byMachineId.set(connection.target.id, { state: connection.state, - repoMatch: null, + option: null, eligible: false, }); } @@ -1088,9 +1141,15 @@ function resolveMachineConnectivity(): Map { // names is connected and carries a usable repo verdict. for (const option of resolveMachineOptions()) { if (option.id === THIS_MACHINE_ID) continue; + const known = byMachineId.get(option.id); + if (known) { + known.option = option; + known.eligible = isEligibleMachineOption(option); + continue; + } byMachineId.set(option.id, { state: "connected", - repoMatch: option.repoMatch, + option, eligible: isEligibleMachineOption(option), }); } @@ -1105,23 +1164,25 @@ function resolveMachineConnectivity(): Map { * about: * * - LIVE. Connected and still hosting this repository. - * - DIMMED. Not connected, and a reconnect attempt has since run to completion - * and failed (or the target is idle, so no attempt is coming). Its lanes and - * chats stay on screen, collapsed and inert, because a machine being asleep - * does not make the work on it stop existing — and yanking a lane group out of - * the list on every wifi blip is what made machines look like they vanish. - * Believing a drop takes at least `UNREACHABLE_FLOOR_MS`, and at most - * `UNREACHABLE_CEILING_MS` when no attempt ever completes. - * - FORGOTTEN. Only two things earn removal: the machine is gone from the + * - DIMMED. Its lanes and chats stay on screen, collapsed and inert, because a + * machine being asleep does not make the work on it stop existing — and + * yanking a lane group out of the list on every wifi blip is what made + * machines look like they vanish. Believing a drop takes at least + * `UNREACHABLE_FLOOR_MS`, and at most `UNREACHABLE_CEILING_MS` when no attempt + * ever completes. A machine that is CONNECTED but whose repository we cannot + * re-prove dims on the same floor: it is not being read, so calling it live is + * a lie — but it is not removed, because absence of proof is not proof of + * absence and a project list that has not caught up after a reconnect must not + * read as "the repo is gone". + * - FORGOTTEN. Three things earn removal: the machine is gone from the * connection snapshot entirely (unpaired or deleted — nothing will ever - * refresh it again), or it has been dimmed for `OFFLINE_RETENTION_MS`. + * refresh it again); it is connected and positively reports the repository + * missing, with an origin to prove it by (the case #941 was about); or it has + * been dimmed for `OFFLINE_RETENTION_MS`. * - * A machine we ARE connected to but cannot re-prove the repository on is left - * exactly as it was. Absence of proof is not proof of absence: a project list - * that has not caught up after a reconnect must not read as "the repo is gone". - * Only a connected machine that positively reports the repository missing is - * dropped, which is what keeps "if it can't be refreshed, it isn't shown" true - * for the case #941 was about. + * The floor and the ceiling are one deadline, not two rules: `UNREACHABLE_FLOOR_MS` + * is never above `UNREACHABLE_CEILING_MS`, so a machine is simply held until the + * one that applies. */ function applyReachability(): void { const store = rootAppStoreApi.getState(); @@ -1153,32 +1214,48 @@ function applyReachability(): void { forgotten.push(machineId); continue; } - if (machine.state === "connected") { - if (machine.repoMatch === "missing") { - forgotten.push(machineId); - continue; - } - runtime.dropsByMachineId.delete(machineId); - if (entry.online) reachable.push(machineId); + // Only an origin can prove a repository absent. `repoMatchFor` will say + // "missing" off a folder-name mismatch alone, and the scope's origin URL is + // re-resolved from the bound machine — so it can be transiently null while + // that machine blips. Deleting rows on that evidence is not recoverable. + if ( + machine.state === "connected" + && machine.option?.repoMatch === "missing" + && (runtime.scope.repoOriginUrl ?? resolveBoundRepoOriginUrl(runtime.scope)) + ) { + forgotten.push(machineId); continue; } const drop = runtime.dropsByMachineId.get(machineId) - ?? { droppedAtMs: nowMs, sawAttempt: false, attemptFailed: false }; + ?? (entry.online + ? { droppedAtMs: nowMs, sawAttempt: false, attemptFailed: false } + // Already dimmed with no drop record: a remount cleared the records + // while the store slice survived. A fresh record would restart the floor + // and flash the machine back to live, so the standing verdict is kept + // and only the retention deadline is re-anchored — to the last + // successful read, the closest thing to when it stopped answering. + : { droppedAtMs: entry.lastSyncedAtMs ?? nowMs, sawAttempt: true, attemptFailed: true }); if (machine.state === "connecting") drop.sawAttempt = true; else if (drop.sawAttempt) drop.attemptFailed = true; runtime.dropsByMachineId.set(machineId, drop); - const elapsedMs = nowMs - drop.droppedAtMs; - // `idle` means the target is not dialing and will not start on its own, so - // waiting for a failed attempt would wait forever. - const answered = drop.attemptFailed || machine.state === "idle"; - if (elapsedMs < UNREACHABLE_FLOOR_MS || !(answered || elapsedMs >= UNREACHABLE_CEILING_MS)) { + // `idle` means the target is not dialing and will not start on its own, and + // `connected` means it answers but cannot be read for this repository — in + // both cases there is no attempt left to wait for. + const answered = drop.attemptFailed + || machine.state === "idle" + || machine.state === "connected"; + const dimAtMs = drop.droppedAtMs + + (answered ? UNREACHABLE_FLOOR_MS : UNREACHABLE_CEILING_MS); + // A machine that is already dimmed stays dimmed until it is eligible again, + // whatever the deadline says — the verdict is the store's, not this tick's. + if (entry.online && nowMs < dimAtMs) { reachable.push(machineId); - noteDeadline(drop.droppedAtMs + (answered ? UNREACHABLE_FLOOR_MS : UNREACHABLE_CEILING_MS)); + noteDeadline(dimAtMs); continue; } - if (elapsedMs >= OFFLINE_RETENTION_MS) { + if (nowMs - drop.droppedAtMs >= OFFLINE_RETENTION_MS) { forgotten.push(machineId); continue; } @@ -1189,6 +1266,7 @@ function applyReachability(): void { for (const machineId of forgotten) { runtime.dropsByMachineId.delete(machineId); runtime.laneReadAtMsByMachineId.delete(machineId); + runtime.unresolvedLaneIdsByMachineId.delete(machineId); } store.dropCrossMachineLanes(forgotten); } @@ -1209,14 +1287,15 @@ function applyReachability(): void { } } -/** Forgets every open drop record. Used by teardown and by scope changes. */ -function resetReachabilityTracking(): void { +/** Forgets every per-machine record. Used by teardown and by scope changes. */ +function resetMachineTracking(): void { if (runtime.graceTimer) { clearTimeout(runtime.graceTimer); runtime.graceTimer = null; } runtime.dropsByMachineId.clear(); runtime.laneReadAtMsByMachineId.clear(); + runtime.unresolvedLaneIdsByMachineId.clear(); } function applySnapshot(snapshot: RemoteRuntimeConnectionSnapshot): void { @@ -1277,7 +1356,7 @@ function detach(): void { clearTimeout(runtime.refreshTimer); runtime.refreshTimer = null; } - resetReachabilityTracking(); + resetMachineTracking(); runtime.refreshQueued = false; runtime.refreshInFlight = false; for (const dispose of runtime.disposers.splice(0)) { @@ -1308,7 +1387,7 @@ export function startCrossMachineLaneSync(scope: CrossMachineLaneScope): () => v // The new scope does wipe every machine slice, though, so a deadline carried // over from the old one could hide a machine with no grace at all the moment // it reappears here. - resetReachabilityTracking(); + resetMachineTracking(); rootAppStoreApi.getState().applyCrossMachineLaneScope(scope.scopeKey); } runtime.scope = scope; From 4fa4d5c9379987c2f88721fc6adbcd3d4a08676f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:00:04 -0400 Subject: [PATCH 4/4] docs: describe the presence, cadence, and Attention rules as they now stand Commit 1's doc pass predated the review fixes, so it still said a connected machine that cannot re-prove the repository keeps its last verdict, and still claimed the cross-machine union never polls. Corrects both, and documents what had no coverage at all: the notch helper's surface- and screen-driven cadence, the presence POST's visible/hidden split, and the two bounds on an Attention snapshot read. Also adds a regression test for the one contract from the review round that had none: a catch-up lane read that resolves after a scope change must not stamp the cadence, or the new scope goes a full cadence with no lane list and therefore no rows at all. Co-Authored-By: Claude Fable 5 --- .../renderer/state/crossMachineLanes.test.ts | 75 +++++++++++++++++++ docs/ARCHITECTURE.md | 6 +- docs/features/remote-runtime/README.md | 41 +++++++--- .../push-notifications.md | 45 +++++++++-- .../features/terminals-and-sessions/README.md | 62 +++++++++++---- 5 files changed, 194 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts index 1ae4e0698..437dd3898 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts @@ -978,6 +978,81 @@ describe("cross-machine refresh scheduling", () => { stop(); }); + it("does not let a late catch-up read suppress a new scope's first lane read", async () => { + vi.useFakeTimers(); + const requests: Array<{ domain: string; action: string }> = []; + const pendingLaneReads: Array<(value: { result: unknown }) => void> = []; + let holdLaneReads = false; + let sessionLaneId = "lane-one"; + const callAction = vi.fn(( + _targetId: string, + _projectId: string, + request: { domain: string; action: string }, + ) => { + requests.push({ domain: request.domain, action: request.action }); + if (request.domain === "lane") { + if (holdLaneReads) { + return new Promise<{ result: unknown }>((resolve) => pendingLaneReads.push(resolve)); + } + return Promise.resolve({ result: { lanes: [] } }); + } + return Promise.resolve({ result: { sessions: [{ id: "session-1", laneId: sessionLaneId }] } }); + }); + const connections = [{ + state: "connected", + target: { id: "target-studio", name: "Mac Studio (12)", hostname: "studio" }, + projects: [{ + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + gitOriginUrl: "git@github.com:acme/repo-a.git", + }], + }]; + window.ade = { + remoteRuntime: { + callAction, + getConnectionSnapshot: vi.fn(async () => ({ connections, connectedCount: 1 })), + onConnectionSnapshotChanged: vi.fn(() => () => {}), + }, + } as unknown as typeof window.ade; + + const scope = { + repoDisplayName: "Repo A", + repoOriginUrl: "git@github.com:acme/repo-a.git", + boundTargetId: null, + boundProjectId: null, + }; + const first = startCrossMachineLaneSync({ ...scope, scopeKey: "local:/repo-a" }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(400); + const laneReadsBefore = requests.filter((entry) => entry.domain === "lane").length; + expect(laneReadsBefore).toBeGreaterThan(0); + + // A chat appears on a lane the machine has never listed, which forces the + // catch-up read — and that read is still in flight when the user switches to + // another checkout of the same repository. + holdLaneReads = true; + sessionLaneId = "lane-unlisted"; + await vi.advanceTimersByTimeAsync(10_500); + expect(pendingLaneReads).toHaveLength(1); + + const second = startCrossMachineLaneSync({ ...scope, scopeKey: "local:/repo-a-copy" }); + first(); + holdLaneReads = false; + pendingLaneReads.splice(0).forEach((resolve) => resolve({ result: { lanes: [] } })); + await Promise.resolve(); + await Promise.resolve(); + + // Stamping the cadence from that stale read would leave the new scope + // without a lane list — and so without a single row — for a full cadence. + const laneReadsAfterSwitch = requests.filter((entry) => entry.domain === "lane").length; + await vi.advanceTimersByTimeAsync(1_000); + expect(requests.filter((entry) => entry.domain === "lane").length) + .toBeGreaterThan(laneReadsAfterSwitch); + + second(); + }); + it("stops polling while the window is hidden and refreshes on the way back", async () => { vi.useFakeTimers(); const callAction = vi.fn(async () => ({ result: { sessions: [] } })); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c8ead3691..52454ad1b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -748,7 +748,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `ai/` | `aiIntegrationService.ts`, `authDetector.ts`, `providerConnectionStatus.ts`, `claudeRuntimeProbe.ts`, `modelsDevService.ts`, `compactionEngine.ts`, `tools/*` | Provider routing, detection, tool definitions, compaction. | | `agentTools/` | `agentToolsService.ts` | Agent tool registry metadata surfaced to the renderer. | | `analytics/` | `productAnalyticsService.ts`, `productAnalyticsPolicy.ts`, `usageProductAnalyticsExporter.ts`, `dailyUsageAnalytics.ts`, `agentTurnProductAnalytics.ts` | Machine-scoped privacy-bounded product analytics: direct PostHog capture transport, consent/kill switches, closed sanitizer, salted identifier hashing, once-only install/activation milestones, pseudonymous account identification, durable quotas/deduplication, usage-ledger export, and coarse aggregate/work-session producers. See [logging.md](./logging.md). | -| `attention/` | `attentionAccountCoordinator.ts`, `attentionNotchHelper.ts`, `attentionNotchRouter.ts` | Account-first desktop Attention boundary plus the native macOS helper lifecycle. The coordinator reads the relay independently of the selected project/remote binding, permits only an explicit local-machine fallback, and fences mutations by loaded account owner/source revision. The helper consumes the renderer snapshot, reports physical-notch vs menu-bar surface state, preserves empty/error availability, and routes native open/refresh/settings/acknowledgment requests back through typed IPC. | +| `attention/` | `attentionAccountCoordinator.ts`, `attentionNotchHelper.ts`, `attentionNotchRouter.ts` | Account-first desktop Attention boundary plus the native macOS helper lifecycle. The coordinator reads the relay independently of the selected project/remote binding, permits only an explicit local-machine fallback, and fences mutations by loaded account owner/source revision. The helper consumes the renderer snapshot, reports physical-notch vs menu-bar surface state, preserves empty/error availability, and routes native open/refresh/settings/acknowledgment requests back through typed IPC. Its refresh cadence is keyed off what is actually on screen: 15 s while it has a reported surface and the display is awake, 60 s while it has none or the screen is locked/suspended. `main.ts` feeds that through `setScreenAwake`, tracking `powerMonitor` lock and suspend as independent facts so a resume after a sleep that did not lock cannot declare a locked screen awake. Changing the interval rebuilds the timer, and a respawned child starts with no surface rather than inheriting the previous one's. | | `appControl/` | `appControlService.ts`, `appControlLaunchCommand.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `appControlLaunchCommand.ts` owns the shell-command detection and debug-flag injection helpers for direct Electron and package-script launches. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). | | `builtInBrowser/` | `builtInBrowserService.ts`, `builtInBrowserAgentAccess.ts`, `builtInBrowserActorCapabilities.ts`, `builtInBrowserAuthentication.ts`, `builtInBrowserProfileMigration.ts`, `builtInBrowserStateStore.ts`, `builtInBrowserNavigation.ts`, `builtInBrowserPermissions.ts`, `builtInBrowserWebAuthn.ts`, `desktopBridgeServer.ts` | In-app web browser owned by the main process. Every remote-content `WebContentsView` uses the single persistent `persist:ade-browser` storage profile (`storageProfileKey: "global"`), while service keys combine the ADE window id with a project/window/personal tab-collection key so visible tabs stay independent. Project roots route project commands and scratch observations; validated personal commands retain the personal tab collection and use the channel-specific machine-local browser-observation scratch root. Neither route partitions cookies or site storage. On first use, a bounded, idempotent migration copies unexpired persistent cookies from this channel's legacy project-derived partitions into the global profile without overwriting global cookies or copying session cookies; it preserves the old partition directories because Chromium DOM storage, IndexedDB, service-worker state, and WebAuthn credentials cannot be safely merged across partitions. The bounded machine-local state store restores HTTP(S)/blank tab URLs and the active tab for each collection, but never restores agent leases, lightweight browser sessions, or synthetic session cookies. The service caps each collection at 10 tabs, routes global-session network events back to their owning collection, drives OAuth popups and downloads, and emits targeted events. HTTP/proxy authentication uses a sandboxed, local credential prompt and passes values directly to Chromium without persisting or logging them; client-certificate requests use an explicit native chooser and only accept a certificate Electron offered. Permission requests are deny-by-default, limited to managed browser web contents and secure origins, and use persisted per-origin/embedding-origin decisions with a native human prompt; only Google's `storage-access` and `top-level-storage-access` requests retain a narrow accounts-domain compatibility exception. The Browser toolbar's trusted-renderer Profile panel exposes non-secret cookie/cache/flush diagnostics and list/remove/clear controls for remembered permission decisions; these operations are not bridged to agents or unbound CLI callers. A separate non-persistent agent-access controller requires a per-chat/lane native human grant for every non-local origin and for local origins with allowed privileged permissions; cross-origin navigations and redirects are intercepted, and sensitive popups are blocked until explicitly approved. The grant follows the agent-owned tab without a timer and clears only when an explicit trusted-renderer navigation reclaims the tab. Tabs carry owner/lease metadata. ADE-launched chats receive opaque in-memory browser actor capabilities bound to their trusted chat/lane/project or personal collection. The runtime requires the token and strips caller routing; Electron validates it in the issuing process, restores only the bound scope, forces `force: false`, and separately authenticates the bridge with the desktop launch's rotating token. Agents cannot force or impersonate a takeover, read another agent's tab status, inspect global cookie-domain diagnostics, or administer permissions. Browser sessions bind one workflow to one tab. Project observations live under `.ade/cache/browser-observations/`; personal observations live under the channel user-data `browser-observations/personal/` root, which is narrowly allowlisted for proof promotion. The issuer-restored scope selects the matching independent tab collection. Navigation/protocol policy lives in `builtInBrowserNavigation.ts`; WebAuthn account selection lives in `builtInBrowserWebAuthn.ts`. | | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. | @@ -771,7 +771,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `keybindings/` | `keybindingsService.ts` | User keybindings read/write. | | `lanes/` | `laneService.ts`, `laneEnvironmentService.ts`, `laneTemplateService.ts`, `laneProxyService.ts`, `portAllocationService.ts`, `autoRebaseService.ts`, `rebaseSuggestionService.ts`, `laneLaunchContext.ts`, `oauthRedirectService.ts`, `runtimeDiagnosticsService.ts` | Worktree lifecycle, env bootstrap, templates, reverse proxy, port leases, auto-rebase, suggestions, OAuth redirect, diagnostics. | | `logging/` | `logger.ts` | File-backed structured logger. | -| `localRuntime/` | `localRuntimeConnectionPool.ts` | Desktop-side client for the local brain endpoint. Spawns or attaches to the machine endpoint, registers local projects with `projects.add`, dispatches local runtime actions with per-call timeouts where needed, emits `local_runtime.action_slow` warn logs (with `ensureProjectMs` / `connectMs` / `daemonCallMs` breakdown) whenever a call exceeds 500 ms or throws and aggregates those calls into a bounded rolling 24 h window exposed as `getRuntimeHealth()` (count + p95) for the `ade.app.getRuntimeHealth` IPC / Storage diagnostics tile, polls/subscribes to runtime events while preserving `eventEpoch`/gap metadata, and installs the background service best-effort in packaged builds. | +| `localRuntime/` | `localRuntimeConnectionPool.ts` | Desktop-side client for the local brain endpoint. Spawns or attaches to the machine endpoint, registers local projects with `projects.add`, dispatches local runtime actions with per-call timeouts where needed, emits `local_runtime.action_slow` warn logs (with `ensureProjectMs` / `connectMs` / `daemonCallMs` breakdown) whenever a call exceeds 500 ms or throws and aggregates those calls into a bounded rolling 24 h window exposed as `getRuntimeHealth()` (count + p95) for the `ade.app.getRuntimeHealth` IPC / Storage diagnostics tile, polls/subscribes to runtime events while preserving `eventEpoch`/gap metadata, and installs the background service best-effort in packaged builds. `callSync` takes a per-call `timeoutMs` so a caller standing in for a live stream can opt out of the long default; `callAttention` uses it to run every Attention poll under the 30 s sync-domain timeout, because a snapshot poll inheriting the ten-minute action budget pins the renderer on syncing long after the account stream has wedged. | | `onboarding/` | `onboardingService.ts`, `onboardingSuggestedConfig.ts` | First-run flow, defaults detection, existing lane discovery. `onboardingSuggestedConfig.ts` contains pure workflow parsing and suggested `.ade/ade.yaml` generation. | | `opencode/` | `openCodeRuntime.ts`, `openCodeServerManager.ts`, `openCodeBinaryManager.ts`, `openCodeInventory.ts`, `openCodeModelCatalog.ts` | OpenCode server spawn, binary resolution, model discovery. | | `orchestration/` | `orchestrationService.ts`, `applyPatches.ts`, `patchPolicy.ts`, `manifestNormalization.ts`, `runtimeProfile.ts` | Work-tab orchestration for multi-phase plans. `orchestrationService` manages run lifecycle, manifest persistence, the `leadState.planning` state machine, `plan.md`, validation strategy/findings, asset bundles, the `lineage` delegation ledger (lead→worker/validator spawn + result edges), and two service-owned durability records: a `receipts` idempotency ledger and a transactional `outbox` chat-delivery queue. Receipts key on a per-request idempotency key so a retried `spawnAgent`/`messageAgent` replays its original result instead of double-spawning; the outbox holds `brief`/`ping`/`lead_status`/`cancel_interrupt`/`completion` deliveries that are written atomically with the state transition that produced them (a worker/validator reaching a terminal state enqueues a `completion` entry in the same transaction) and drained event-driven with bounded backoff, so the lead can never miss a completion. Worker/validator completion is event-driven (no transcript polling), heartbeats coalesce and a stall sweep flips `agent.stalled` for silent-but-`running` workers with a single plain-language lead notification, and cancellation reaches native worker processes. Runs also carry a per-run `finishing` decision (`worktree` vs. push-PR-and-update-Linear), a `goalSource`, `scheduledFollowups`, a declared `capabilities` policy, and evidence asset kinds (`proof_artifact`/`computer_use`/`video`/`pr_link`/`linear_issue`/`deeplink`) with `externalRef` + `registeredBySessionId`. `patchPolicy` keeps privileged fields (`leadState.planning`, `planSpec`, the `/lineage` ledger, and the `/receipts` + `/outbox` records) behind service methods so the lead cannot forge intake, planning rounds, model routing, approval readiness, delegation edges, or delivery/idempotency state with a raw patch. `runtimeProfile` resolves the active orchestration profile per session and gates model selection / plan approval on planning readiness. The renderer surfaces live in `renderer/components/orchestration/` (see §7.3). The former `orchestrator/` and `missions/` directories were consolidated into this service. | @@ -835,7 +835,7 @@ Electron renderer runtime does **not** wrap the app in `React.StrictMode`. Brows - Narrow selectors on components to minimize re-renders. - `refreshLanes` accepts independent lane-status and lane-snapshot flags. Callers can refresh cheap runtime snapshot decorations without recomputing git status, or update git status without rebuilding conflict/rebase/auto-rebase overlays; statusless refreshes preserve the previous `LaneStatus`/`parentStatus` in store so the UI does not flicker to unknown git state. - Per-project work-view state keyed by project identity (`WorkProjectViewState`): local bindings use their root path, while remote bindings use `OpenProjectBinding.key` (`remote::`). Alongside Work filters, collapsed section ids, and right-sidebar state, version 3 includes the Lanes tab's filter, pinned lane ids, and expanded lane id. Version 4 adds the Work-sidebar-only `workPinnedLaneIds`, lane sort mode, sparse manual lane order, and structured session chips; normalization is additive, so a v3 blob retains the former Created order with no Work pins or chips. It keeps Settled reachable as a quiet collapsed tail: Status/Time mode use `status:settled`, Lane mode uses explicit `settled-open:` markers, and a fully quiet lane uses the inverted `lane-open:` marker. There is no independent Tiers/Show settled filter. The one-time status-collapse migration is gated against its own version-2 threshold, so later additive schema bumps do not re-collapse a section the user expanded. Persistence under `ade.workViewState.v1` is a scoped delta read-modify-write: every mounted project store upserts or deletes only the project/lane keys it owns instead of flushing its stale copy of the whole map. `refreshLanes` prunes lane scopes only from a non-empty lane inventory and only inside that store's project scope. `registerProjectSurfaceStore` / `workViewStoreForProject` route project-specific writes made by `AppShell` and `TopBar`, which render above `AppStoreProvider`, to the owning surface store. Lane/status deeplinks are transient view overrides and user filter, grouping, chip, pin, or ordering changes return to and then update the saved base. The right-edge fields are `workSidebarOpen`, `workSidebarTab` (`"git" | "files" | "ios" | "app-control" | "browser"`), and `workSidebarWidthPct` (clamped 26–55). The sidebar consolidates lane-scoped tools that were previously split across separate floating panes; per-chat iOS / App Control drawers still exist on `AgentChatPane` but are suppressed when the chat is mounted as a Work tile so the sidebar owns those surfaces at lane scope. Remote-bound Work sidebars expose only the runtime-backed Git and Files tabs; local-only iOS Simulator, App Control, and Browser panes stay hidden. The `browser` tab is not lane-scoped on local bindings: each ADE window/project keeps its own tab collection and inspect state, while browser authentication storage is global to the installation/channel through `persist:ade-browser`. -- Cross-machine Work union. `crossMachineLanesByMachineId` + `crossMachineLaneScopeKey` (fed by `renderer/state/crossMachineLanes.ts`) hold every *other* connected machine's lanes and sessions for the repository the active tab is showing, so the Work sidebar can list chats in flight anywhere without changing the tab's binding. Keyed by machine because a lane owns its machine (`lanes.worktree_path` is an absolute path on exactly one machine) and chats inherit theirs through `laneId` — there is no per-chat machine field. `mergeCrossMachineLanes` retains omitted `lanes`/`sessions` so a failed read leaves the machine's rows on screen, and `setCrossMachineMachinesOnline` flags rather than deletes an entry that goes offline — the sidebar renders it dimmed, collapsed, and inert, and the retained slice also backs the push-divergence guard. Presence is decided in one place, `applyReachability`, from connection state alone: a drop is believed only after a reconnect attempt has completed and failed (`connecting` observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling for a dial that never finishes and an immediate verdict for an `idle` target, so the `connecting`/`error` states a redial or sleep/wake publishes do not reflow the sidebar; reconnecting is applied instantly. Only `dropCrossMachineLanes` deletes, and only for a target gone from the snapshot, a connected machine that positively reports the repository missing, or 24 hours unreachable. The scope key is per repository, so a project-tab switch invalidates the union wholesale. A detached chat launch whose runtime pin differs from the active binding seeds an optimistic summary directly into the owning machine slice; the binding/session-keyed pending record survives stale in-flight list responses and is replaced by the authoritative row with the same stable session id (or pruned on delete/expiry). This keeps the active binding free of foreign UUID-lane placeholders while avoiding a blank interval before the next remote list arrives. Foreign lanes use the same `sessionFilingBucket` active/snoozed/settled partition, fully quiet collapsed header, and quiet-tail renderer as local lanes, with composite machine/lane persistence keys. Their **Manage lane** dialog and mutations carry the same owning binding. Sessions whose referenced lane is absent render as explicit warning-tinted **Orphaned sessions** groups with a refresh-only recovery action; ADE preserves the sessions and never interprets unknown ownership as permission to mutate the active machine. Refreshes ride the connection-snapshot subscription plus existing lane-lifecycle/session-changed events (coalesced, bounded, timed out, capped in parallelism) and a fallback loop for machines with no renderer change feed; that loop is paused entirely while the window is hidden, re-reads chats every 10 s, and re-reads lanes on a 30 s cadence (or immediately when a chat names a lane the machine has never reported), because `lane.list` with `includeStatus` costs a git status per lane on the other machine. Foreign reads never gate the local list. `selectOtherMachineBranchStates` is the memoized selector the push-divergence guard reads at click time. +- Cross-machine Work union. `crossMachineLanesByMachineId` + `crossMachineLaneScopeKey` (fed by `renderer/state/crossMachineLanes.ts`) hold every *other* connected machine's lanes and sessions for the repository the active tab is showing, so the Work sidebar can list chats in flight anywhere without changing the tab's binding. Keyed by machine because a lane owns its machine (`lanes.worktree_path` is an absolute path on exactly one machine) and chats inherit theirs through `laneId` — there is no per-chat machine field. `mergeCrossMachineLanes` retains omitted `lanes`/`sessions` so a failed read leaves the machine's rows on screen, and `setCrossMachineMachinesOnline` flags rather than deletes an entry that goes offline — the sidebar renders it dimmed, collapsed, and inert, and the retained slice also backs the push-divergence guard. Presence is decided in one place, `applyReachability`, from connection state alone: a drop is believed only after a reconnect attempt has completed and failed (`connecting` observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling for a dial that never finishes. Two states have no attempt left to wait for and dim on the floor alone: `idle`, which will not redial on its own, and `connected` but unable to re-prove this repository, which answers yet is never read for it. The floor and the ceiling are one deadline, not two rules. The `connecting`/`error` states a redial or sleep/wake publishes therefore do not reflow the sidebar, and reconnecting is applied instantly. The verdict belongs to the store rather than the tick: a machine that is already dimmed stays dimmed until it is eligible again, so a Work-tab remount — which tears down the shared runtime and its drop records while the store slice survives — cannot re-brighten it for another floor; the retention deadline is re-anchored to that machine's last successful read instead. Only `dropCrossMachineLanes` deletes, and only for a target gone from the snapshot, a connected machine that positively reports the repository missing *and* has a resolvable origin to prove it by (`repoMatchFor` will say "missing" off a folder-name mismatch alone, and the scope's origin is transiently null while the bound machine blips), or 24 hours unreachable. The scope key is per repository, so a project-tab switch invalidates the union wholesale. A detached chat launch whose runtime pin differs from the active binding seeds an optimistic summary directly into the owning machine slice; the binding/session-keyed pending record survives stale in-flight list responses and is replaced by the authoritative row with the same stable session id (or pruned on delete/expiry). This keeps the active binding free of foreign UUID-lane placeholders while avoiding a blank interval before the next remote list arrives. Foreign lanes use the same `sessionFilingBucket` active/snoozed/settled partition, fully quiet collapsed header, and quiet-tail renderer as local lanes, with composite machine/lane persistence keys. Their **Manage lane** dialog and mutations carry the same owning binding. Sessions whose referenced lane is absent render as explicit warning-tinted **Orphaned sessions** groups with a refresh-only recovery action; ADE preserves the sessions and never interprets unknown ownership as permission to mutate the active machine. Refreshes ride the connection-snapshot subscription plus existing lane-lifecycle/session-changed events (coalesced, bounded, timed out, capped in parallelism) and a fallback loop for machines with no renderer change feed; that loop is paused entirely while the window is hidden, re-reads chats every 10 s, and re-reads lanes on a 30 s cadence, because `lane.list` with `includeStatus` costs a git status per lane on the other machine. A chat naming a lane that machine has never reported forces the lane read immediately, but only once: lane ids a completed read did not explain are remembered until the next one, because `session.list` does not filter on lane status while `lane.list` excludes archived lanes, so a chat on an archived lane is permanently unresolvable and would otherwise demand the expensive read on every tick forever. Foreign reads never gate the local list. `selectOtherMachineBranchStates` is the memoized selector the push-divergence guard reads at click time. - Project tab bookkeeping. `openProjectTabRoots: string[]` tracks local roots open in the window (mirrored to the main process via `ade.app.setWindowProjectTabs` so background services keep those projects warm); `openRemoteProjectTabs` tracks full remote bindings so inactive remote tabs remain first-class retained surfaces. `ProjectTabHost` applies one shared eight-surface LRU across local and remote tabs: inactive mounted surfaces are hidden, inert, and animation-paused, while an open surface that falls outside the bound snapshots its scoped state back into the root caches before unmounting. `projectInfoByRoot: Record` caches local `ProjectInfo` payloads for tab favicons and offline tab rendering. - Stale-while-revalidate switch caches. `laneSelectionByProject` remembers the `{ laneId, sessionId }` selection per project identity so switching tabs lands on the lane/chat the user last had open instead of "first lane". `laneCacheByProject` mirrors the last good `{ lanes, laneSnapshots }`; local and remote switches apply it immediately (no spinner, no chat-pane unmount) and refresh silently in the background. `sessionsCacheByProject` does the same for `useWorkSessions` so chat tabs and terminal grids do not blank during a tab swap. `projectRouteStorage.ts` persists the last route under the binding key, independent of whether the surface is currently mounted. Cache pruning retains every open local root and remote binding key; tab close and target disconnect deliberately evict only their affected remote state. The two eviction paths are distinct: `evictProjectState(key)` + `removeStoredProjectRoute(key)` is the full "forget this surface" wipe used by an explicit tab close and by removing a machine, while `evictProjectDataCaches(key)` is the disconnect-only sibling that drops just the snapshots that can go stale while a remote is unreachable (`laneCacheByProject`, `laneSelectionByProject`, `sessionsCacheByProject`, and the persisted lane cache) and preserves `workViewByProject` / `laneWorkViewByScope` plus the stored route so reconnecting restores the chat or tile that was open. `closeProject({ preserveRemoteViewState: true })` applies the same narrower rule when a disconnect closes the last remaining tab. - `projectRevision` is a monotonically incrementing counter bumped inside `setProject` whenever the active project root actually changes. Long-lived renderer-side caches (most notably the module-level xterm runtime cache in `TerminalView.tsx`) combine it with the project identity key, so identical paths on different remote targets cannot share PTY runtimes. All project-transition paths (`refreshProject`, `openRepo`, `switchProjectToPath`, `closeProject`) go through `setProject` to keep the counter honest. diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 439c6236c..f8939ab40 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -176,20 +176,41 @@ relay payload E2E encryption is planned security work. See the trust boundary in inherit theirs through `laneId`, so the union is keyed by machine and holds lanes; there is deliberately no per-chat machine field. Refreshes are driven by the connection-snapshot subscription and existing lane-lifecycle / - session-changed events (coalesced, no polling); foreign reads are bounded, + session-changed events (coalesced), plus a fallback loop for machines that + publish no renderer change feed. That loop is visibility-gated: it stops + entirely while the window is hidden and refreshes once on the way back, re-reads + chats every 10 s, and gives the lane list its own 30 s cadence because + `lane.list` with `includeStatus` resolves a git status per lane on the other + machine. A chat naming a lane that machine has never reported forces the lane + read immediately, but only once — ids a completed read did not explain are + remembered, since `session.list` does not filter on lane status while + `lane.list` excludes archived lanes, and a chat on an archived lane is + permanently unresolvable. Foreign reads are bounded, timed out, capped at four machines in parallel, and never gate the local list. A machine that drops is **dimmed, not deleted**: its lanes and chats stay on screen, collapsed and inert, with the offline form of the machine marker naming - it. Rows leave for two reasons only — the machine is gone from the connection - snapshot (unpaired or removed), or it has been unreachable for 24 hours. - Believing a drop takes a completed, failed reconnect attempt (`connecting` + it. Believing a drop takes a completed, failed reconnect attempt (`connecting` observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling - for a dial that never finishes and an immediate verdict for an `idle` target - that will not redial at all: every redial publishes `connecting` and a single - failed liveness ping flips a target to `error`, so a shorter rule dims the - sidebar on every wifi blip. A machine that is connected but cannot re-prove - this repository keeps its last verdict; only one that positively reports the - repository missing is dropped. Coming back is applied instantly. The union is + for a dial that never finishes: every redial publishes `connecting` and a + single failed liveness ping flips a target to `error`, so a shorter rule dims + the sidebar on every wifi blip. Two states skip the wait for an attempt that is + never coming and dim on the floor alone — an `idle` target, which will not + redial at all, and a machine that is connected but cannot re-prove this + repository, which answers but is never read for it. The second is dimmed and + not removed: absence of proof is not proof of absence, and a project list that + has not caught up after a reconnect must not read as "the repo is gone". Coming + back is applied instantly, and the verdict survives a Work-tab remount — a + dimmed machine brightens only by becoming eligible again, never because the + runtime that held its drop record was torn down. + Rows leave for three reasons: the machine is gone from the connection snapshot + (unpaired or removed), it positively reports the repository missing *and* there + is a resolvable origin to prove that by, or it has been unreachable for 24 + hours. The origin requirement is what keeps a healthy machine's rows alive + while the bound machine blips — `repoMatchFor` will answer "missing" off a + folder-name mismatch alone, and the scope's origin URL is re-resolved from the + bound machine, so it can be transiently null. Deleting rows on that evidence is + not recoverable. + The union is scoped per repository, so switching project tabs invalidates it wholesale. `selectOtherMachineBranchStates` is the derived-state seam the push guard reads at click time. diff --git a/docs/features/sync-and-multi-device/push-notifications.md b/docs/features/sync-and-multi-device/push-notifications.md index 751f404b4..069a868b3 100644 --- a/docs/features/sync-and-multi-device/push-notifications.md +++ b/docs/features/sync-and-multi-device/push-notifications.md @@ -264,9 +264,26 @@ The full route provides: - account delivery/privacy controls. Presence reports include foreground state, whether an ambient Attention surface -is visible, and the currently visible item ids. An item is marked seen only -after its exact destination opens successfully. Account changes and stale -machine revisions fail closed and require a refresh. +is visible, and the currently visible item ids. They are posted every 30 s while +the ADE window is visible and every 120 s while it is hidden, plus immediately +on focus, on blur, and on becoming visible again: a hidden window still has to +hold its claim, but it does not need to hold it at foreground rates, and a +120 s-stale "hidden" claim right as the user returns is the one case that +misleads other devices. Going hidden does not force an extra report, because +`blur` has already reported the foreground change. + +An item is marked seen only after its exact destination opens successfully. +Account changes and stale machine revisions fail closed and require a refresh. + +Every snapshot read is bounded twice. The local-brain fallback is issued as a +sync call carrying the 30 s sync-domain timeout rather than the connection +pool's ten-minute action budget, so an Attention poll cannot outlive the account +stream it is standing in for. Above it, the renderer races a 75 s backstop, +sized to clear a 15 s relay request, one forced 401 retry, and that 30 s +fallback in sequence — a shorter race would discard a slow-but-successful +snapshot and replace a real host error with a generic timeout. When the backstop +wins, Attention reports that it took too long and offers a retry instead of +leaving the header pinned on syncing. ## Hosted web Attention @@ -305,11 +322,23 @@ Electron renderer supplies the already-synced Attention snapshot and settings; the helper does not create a second account poller. While ADE is hidden or minimized, the running helper asks the existing -renderer/runtime Attention path to refresh on a narrow cadence. Visible windows -keep their normal renderer-owned poll, so the helper does not duplicate -foreground work or talk to the relay independently. If a connected host is too -old to expose `attention.call`, ADE surfaces update-and-restart guidance instead -of presenting an empty notch as if no work existed. +renderer/runtime Attention path to refresh. A visible window ignores that +request and keeps its own 15 s renderer-owned poll, so the helper never +duplicates foreground work or talks to the relay independently. + +The helper's cadence follows what is actually on screen: 15 s while it has a +live surface and the display is awake, 60 s otherwise — before the child has +reported a surface at all, and whenever the screen is locked or the system is +suspended, because nobody is reading a notch on a sleeping display. Lock and +suspend are tracked as two independent facts, since sleeping does not always +lock the machine and a resume must not declare the screen awake while it is +still locked. Changing the interval rebuilds the timer rather than leaving the +old one running, and a respawned helper starts with no surface again instead of +inheriting the dead child's. + +If a connected host is too old to expose `attention.call`, ADE surfaces +update-and-restart guidance instead of presenting an empty notch as if no work +existed. The helper uses a borderless non-activating `NSPanel` above the status bar, joins Spaces/full-screen, and keeps the outer window fixed while the inner diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index eb8e18d2a..a27051f35 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -528,9 +528,10 @@ Renderer surfaces: (`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 — 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 (two or more foreign machines are on screen, or the branch also + `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 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 @@ -575,6 +576,18 @@ Renderer surfaces: `sessionFilingBucket`; the Has PR result reuses the coalesced PR snapshot 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. +- `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 + that dims while the menu is open disables every action in it — they all run on + the owning machine. - `apps/desktop/src/renderer/state/crossMachineLanes.ts` — repository-scoped union loader and optimistic foreign-chat ownership bridge. A detached launch that targets a binding other than the active project is inserted immediately @@ -588,21 +601,42 @@ Renderer surfaces: 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 failed — `connecting` seen while dropped, then a non-connected state — with a - 45 s floor, a 120 s ceiling for a dial that never finishes, and an immediate - verdict for an `idle` target that will not redial. `lastAttemptedAt` cannot - answer this alone: a failed RPC over an established connection stamps it too, - and that is the event most drops start with. Removal is reserved for a target - missing from the snapshot, a connected machine that positively reports the - repository missing, and 24 hours unreachable (`dropCrossMachineLanes`). A timer - re-runs the check at the next deadline, since a machine held through its floor - produces no further snapshot on its own, and the records are cleared on - teardown and on scope change. + 45 s floor and a 120 s ceiling for a dial that never finishes; floor and + ceiling are one deadline, not two rules. Two states have no attempt left to + wait for and dim on the floor alone: an `idle` target that will not redial, and + a `connected` machine whose repository cannot be re-proven, which is eligible + for display but ineligible for refresh — calling it live would be a lie, so it + dims, but it is not removed, because absence of proof is not proof of absence. + `lastAttemptedAt` cannot answer any of this alone: a failed RPC over an + established connection stamps it too, and that is the event most drops start + with. The verdict lives in the store, not the tick — an already-dimmed machine + brightens only by becoming eligible again, so leaving Work and coming back, + which tears down the shared runtime and its drop records while the store slice + survives, cannot flash the machine live for another floor; its retention + deadline is re-anchored to its last successful read instead. Removal is + reserved for a target missing from the snapshot, a connected machine that + positively reports the repository missing *with* a resolvable origin to prove + it by (`repoMatchFor` will say "missing" off a folder-name mismatch, and the + scope's origin is re-resolved from the bound machine and can be transiently + null), and 24 hours unreachable (`dropCrossMachineLanes`). A timer re-runs the + check at the next deadline, since a machine held through its floor produces no + further snapshot on its own, and the records are cleared on teardown and on + scope change. Reads are visibility-gated: the loop stops entirely while the window is hidden and refreshes once on the way back. Chats are re-read every 10 s; the lane list has its own 30 s cadence because `lane.list` with `includeStatus` resolves a git status per lane and writes a state-snapshot row per lane on the other - machine — a chat referencing a lane that machine has never reported forces the - lane read immediately, so nothing is invisible while it waits. + machine. A chat referencing a lane that machine has never reported forces the + lane read immediately, so nothing is invisible while it waits — but only once. + `resolveLaneCadence` owns that rule for both read paths and remembers the lane + ids a completed read did not explain, because `session.list` does not filter on + lane status while `lane.list` asks for `includeArchived: false`: a chat on an + archived lane is permanently unresolvable and would otherwise demand a fresh + `includeStatus` read on every tick, costing more than before the cadence + existed. The cadence is stamped only when lanes were actually read, and only + when the read resolved inside the scope that asked for it, so a response + landing after a project-tab switch cannot suppress the new scope's first lane + read. - `apps/desktop/src/renderer/components/terminals/SessionCard.tsx` — per-session card (status dot, title, preview line, tool type, lane, delta chips). Any session with `orchestrationParentSessionId` renders a