diff --git a/docs/agent-stream-disclosure.md b/docs/agent-stream-disclosure.md index 9cde8a44ff..1d5caefcf2 100644 --- a/docs/agent-stream-disclosure.md +++ b/docs/agent-stream-disclosure.md @@ -1,8 +1,8 @@ # Agent stream disclosures Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group, -a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the -first painted frame. No animation. +a tool call's inline details, a todo card, a long user message's show more, a plan card) keeps the +tapped trigger at the same screen y in the first painted frame. No animation. ## The contract @@ -14,12 +14,20 @@ const handleToggle = () => hold(() => setExpanded((value) => !value)); ``` `view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's -`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row, -`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place. +`holdRow(rowId, apply, triggerEdge)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a +timeline row, `apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place. +A toggle that calls `setState` directly skips the hold: on Android the expanded content paints +over the next row for a frame before the list learns the new size. + +`triggerEdge` names the edge of the row the trigger sits on, and the strategy keeps that edge in +place. Header triggers sit on the top edge, the default. A long user message's show more sits under +the text it opens, so `MessagePreview` passes `{ triggerEdge: "bottom" }`: the text grows and shrinks +above the trigger, and the rows below stay put. Following output: -- A hold always detaches the bottom anchor controller (`holdForDisclosure`). +- A hold detaches the bottom anchor controller (`holdForDisclosure`), except a bottom-edge hold on + native while following: there the end pin already keeps the row's bottom in place. - When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes @@ -33,13 +41,16 @@ ends at its end and the trigger moves by the difference. ## Web -`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores -the row's top after layout, before paint (`strategy-web.tsx`). +`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading +reconcile restores that edge after layout, before paint (`strategy-web.tsx`). The pin records the +scroll offset it was taken at, because the browser clamps `scrollTop` when a collapse shrinks the +content past the offset, before the reconcile reads it. Restoring from the clamped offset would +apply the shrink twice. ## Native On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do -not hold. Summary lines and todo cards expand inline. +not hold. Summary lines, todo cards, plan cards and show more expand inline. The timeline is a LegendList in forward order: one `data` array of history rows followed by the live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size @@ -51,14 +62,14 @@ toggle; its applied commit took up to 3.5 s of JS on a long turn. One owner keeps the scroll offset at any moment: -| State | Owner | -| ---------------------- | ----------------------------------------------------------------------------------------- | -| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. | -| Following the end | The end pin (`native-end-pin.tsx`). | -| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. | -| User scroll | The finger. The controller detaches at drag start. | -| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. | -| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. | +| State | Owner | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. | +| Following the end | The end pin (`native-end-pin.tsx`), also for a bottom-edge hold. | +| Disclosure hold | LegendList's `maintainVisibleContentPosition` on the hold's anchor row, or the end pin for the last row's bottom. | +| User scroll | The finger. The controller detaches at drag start. | +| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. | +| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. | The end pin is a zero-height view at the bottom of the content and the content's first native child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The @@ -78,15 +89,38 @@ jumps by the keyboard height after the keyboard settles. ### The hold -`holdRow(rowId, apply)` runs in the toggle's event: +`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event: -1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and - LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row - (`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible - one down, and a size change with no data change keeps the first row in view whatever +1. Pick the anchor and detach the controller (`holdForDisclosure`). LegendList restores row + tops only, so a top-edge hold anchors the toggled row and a bottom-edge hold anchors the row + below it, whose top is the toggled row's bottom (`disclosureAnchor`). The pin goes away + and LegendList's `maintainVisibleContentPosition` takes over, restoring only the anchor row + (`shouldRestorePosition`). A bottom-edge hold while following skips this step: the end pin + keeps the content end, and the row's bottom with it, in the frame the new height mounts. + + A bottom-edge hold on the last row, such as a prompt the agent has not answered yet, has no + row below it: its bottom is the content end (the running indicator is the list footer, not a + row). The hold mounts the end pin while detached, and `apply` runs a frame later. The pin + anchors only once it is mounted, and a pin that mounts in the same commit as the new height + anchors nothing. The pin's commit mounts before that frame's JS callbacks run, so `apply`'s + commit mounts with the pin already in place. The pin keeps the distance from the end, and each + content size change clamps it to the scroll room left, so the hold re-sticks if the list was + at its end when tapped or a collapse used up the room. The release tracks that distance + instead of reading the offset: the new content size reaches JS before the pin's scroll does. + A reply landing below the row ends the pin in the commit that mounts the reply, so the reply + grows below the row and the trigger stays where it was. A reply that lands before `apply` runs + moves the hold to the reply's row. + + Upstream LegendList anchors only rows from the first fully visible one down to the last fully + visible one, and a size change with no data change keeps the first row in view whatever `shouldRestorePosition` says. A row cut off at the top would then let the row below it hold - instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off - at the top as in view and applies `shouldRestorePosition` to size changes too. + instead, and a bottom-edge anchor just under the viewport would not hold at all. The LegendList + patch counts the rows cut off at the top, and the row just past the bottom, as in view and + applies `shouldRestorePosition` to size changes too. It also drops LegendList's Android clamp + prediction: the scroll view clamps after the adjustment + ([scroll view corrections](#scroll-view-corrections)), so the prediction moved a collapse near + the end to the end. + 2. Run `apply`. 3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so LegendList's container does not re-render and would learn the new height from `onLayout` a task @@ -130,7 +164,7 @@ With every turn collapsed, flinging from a cold open into history did not blank ### LegendList patch -`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists +`patches/@legendapp+list+3.7.1.patch` carries twelve changes for the timeline. Its header lists them by purpose, and [legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream. Drop a hunk when the installed LegendList ships its fix. diff --git a/docs/architecture.md b/docs/architecture.md index a7ad69c14d..de6c619ae3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -276,7 +276,7 @@ its teardown; reconnect creates new server IDs for surviving client handles. See **Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). -Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect. +Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting; a window with no validated inbound frame is a miss, and two misses in a row tear the socket down. When the app returns to the foreground the client runs a fresh cycle of two 5-second windows back to back, then reconnects without backoff, because a socket that died while the OS had JS suspended stays `OPEN` and accepts sends: only inbound frames prove it. Misses and probes from before the suspension do not count toward that cycle, and a window whose timer fires long after its deadline spanned another suspension, so it is discarded and the cycle starts over. Any complete frame inside a window keeps the connection, so a slow multi-frame transfer survives; a single frame that takes longer than the two windows to arrive after foreground does not, and the fix for that is smaller daemon-to-client frames, not a longer window. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect. Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id. diff --git a/docs/hover.md b/docs/hover.md index fe8b539edc..9d523be064 100644 --- a/docs/hover.md +++ b/docs/hover.md @@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor. -For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content. +For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. Only one card per overlay layer is open: pointing at another trigger replaces the open card at once, so a sweep down a list never shows two cards or leaves one behind. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content. Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook. diff --git a/docs/terminal-worker.md b/docs/terminal-worker.md index 77aa9325b7..d83dea9957 100644 --- a/docs/terminal-worker.md +++ b/docs/terminal-worker.md @@ -15,7 +15,7 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term - The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again). - Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon. -The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut. +The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. It also restores SGR mouse encoding: the addon preserves mouse tracking but omits its encoding, so switching away and back otherwise sends legacy wheel reports that Copilot CLI cannot use. These missing modes require xterm's internal services because its public modes API omits them. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut. Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon. diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index e12b4a495a..cef349dbfd 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -69,8 +69,8 @@ Source coverage still includes interleaved tool updates. Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received agent messages each start a fallback turn. This deterministic fallback is shared by paging -and presentation; it cannot recover historical batching or steering. Older history remains -user-driven by scrolling upward. +and presentation; it cannot recover historical batching or steering. An underfilled viewport +loads older history until it fills or history is exhausted. Further history is user-driven. A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling. A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active diff --git a/packages/app/e2e/browser/agent-turn-end.spec.ts b/packages/app/e2e/browser/agent-turn-end.spec.ts index 523297729c..4c8a1828b1 100644 --- a/packages/app/e2e/browser/agent-turn-end.spec.ts +++ b/packages/app/e2e/browser/agent-turn-end.spec.ts @@ -198,3 +198,130 @@ test.describe("Turn end", () => { expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input); }); }); + +async function expectTimingInsideFooter(page: Page): Promise { + const label = page.getByRole("button", { name: /^Worked for/ }).last(); + const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => { + const labelBounds = element.parentElement!.getBoundingClientRect(); + const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect(); + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const lines: boolean[] = []; + while (walker.nextNode()) { + const range = document.createRange(); + range.selectNodeContents(walker.currentNode); + for (const rect of range.getClientRects()) { + lines.push( + rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 && + rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 && + rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 && + rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5, + ); + } + } + return lines; + }); + expect(paintedLines.length).toBeGreaterThan(0); + expect(paintedLines.every(Boolean)).toBe(true); +} + +async function expectFooterWithinWidth(page: Page, width: number): Promise { + const footer = page.getByTestId("turn-footer").last(); + await footer.evaluate((element, availableWidth) => { + element.style.width = `${availableWidth}px`; + }, width); + const label = page.getByRole("button", { name: /^Worked for/ }).last(); + await expect(label).toBeVisible(); + await expectTimingInsideFooter(page); + const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ }); + const before = await controls.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + }), + ); + const footerBefore = await footer.boundingBox(); + const labelBefore = await label.boundingBox(); + await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } }); + await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/); + expect(await label.boundingBox()).toMatchObject({ + x: labelBefore!.x, + width: labelBefore!.width, + height: labelBefore!.height, + }); + expect( + await controls.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + }), + ), + ).toEqual(before); + expect(await footer.boundingBox()).toMatchObject({ + x: footerBefore!.x, + y: footerBefore!.y, + width: footerBefore!.width, + }); + await expectTimingInsideFooter(page); + await page.mouse.move(0, 0); + await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for"); +} + +test("footer timing fits the chat column and swapping labels keeps controls in place", async ({ + page, + agent, +}) => { + await openAgentRoute(page, agent); + await expectFooterWithinWidth(page, 140); + await expectFooterWithinWidth(page, 150); + await expectFooterWithinWidth(page, 300); + await expectFooterWithinWidth(page, 880); +}); + +async function visibleFooterLeftEdges(page: Page): Promise { + return page.evaluate(() => { + const viewport = Array.from( + document.querySelectorAll('[data-testid="agent-chat-scroll"]'), + ).find((candidate) => candidate.checkVisibility()); + return Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? []) + .filter((footer) => footer.checkVisibility()) + .map((footer) => Math.round(footer.getBoundingClientRect().left)); + }); +} + +async function expectFootersInOneColumn(page: Page): Promise { + await expect + .poll(async () => { + const edges = await visibleFooterLeftEdges(page); + return edges.length >= 2 && new Set(edges).size === 1 ? "one column" : edges; + }) + .toBe("one column"); +} + +test("keeps the latest turn's footer in the chat column while it runs and after it ends", async ({ + page, + agent, +}) => { + // Wider than the chat's max width, so the column is centered with space on both sides. + await page.setViewportSize({ width: 1600, height: 1000 }); + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, agent); + await expectAgentIdle(page); + + gate.holdNextAgentStreamEvent("turn_completed"); + gate.holdNextAgentUpdate(agent.agentId, "idle"); + gate.setServerMessageSuppressed("agent_update", true); + await submitMessage(page, "emit 1 coalesced agent stream updates"); + await Promise.all([ + gate.waitForHeldAgentStreamEvent("turn_completed"), + gate.waitForHeldAgentUpdate(agent.agentId, "idle"), + ]); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expectFootersInOneColumn(page); + + gate.releaseHeldAgentStreamEvent("turn_completed"); + gate.releaseHeldAgentUpdate(agent.agentId, "idle"); + gate.setServerMessageSuppressed("agent_update", false); + await expectAgentIdle(page); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expectFootersInOneColumn(page); +}); diff --git a/packages/app/e2e/browser/archive-tab.spec.ts b/packages/app/e2e/browser/archive-tab.spec.ts index 6ee80ddf1e..0dd5641184 100644 --- a/packages/app/e2e/browser/archive-tab.spec.ts +++ b/packages/app/e2e/browser/archive-tab.spec.ts @@ -6,6 +6,7 @@ import { connectSeedClient } from "../support/helpers/seed-client"; import { createTempGitRepo } from "../support/helpers/workspace"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { + closeActiveAgentWithShortcut, archiveAgentFromDaemon, archiveAgentFromSessions, clickSessionRow, @@ -20,8 +21,12 @@ import { openWorkspaceWithAgents, primeAdditionalPage, resetSeededPageState, + unarchiveAgentFromConversation, reloadWorkspace, } from "../support/helpers/archive-tab"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { expectComposerDraft, fillComposerDraft, submitMessage } from "../support/helpers/composer"; +import { awaitAssistantMessage, expectAgentIdle } from "../support/helpers/agent-stream"; import { expectAgentTabActive } from "../support/helpers/launcher"; test.describe("Archive tab reconciliation", () => { @@ -51,6 +56,34 @@ test.describe("Archive tab reconciliation", () => { await tempRepo?.cleanup(); }); + test("restoring a closed agent retains its unsent composer draft after reload", async ({ + page, + }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "archived-composer-", + title: "Restore unsent draft", + }); + try { + await openAgentRoute(page, agent); + await submitMessage(page, "A completed conversation before closing"); + await awaitAssistantMessage(page); + await expectAgentIdle(page); + await fillComposerDraft(page, "Keep this unsent message after closing"); + await expectComposerDraft(page, "Keep this unsent message after closing"); + await closeActiveAgentWithShortcut(page, agent.agentId); + await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull(); + await page.reload(); + await openSessions(page); + await expectSessionRowArchived(page, "Restore unsent draft"); + await clickSessionRow(page, "Restore unsent draft"); + await expectArchivedAgentFocused(page, agent.agentId); + await unarchiveAgentFromConversation(page); + await expectComposerDraft(page, "Keep this unsent message after closing"); + } finally { + await agent.cleanup(); + } + }); + test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => { const archived = await createIdleAgent(client, { cwd: tempRepo.path, diff --git a/packages/app/e2e/browser/branch-switcher.spec.ts b/packages/app/e2e/browser/branch-switcher.spec.ts index 9799242390..0f37f83b10 100644 --- a/packages/app/e2e/browser/branch-switcher.spec.ts +++ b/packages/app/e2e/browser/branch-switcher.spec.ts @@ -27,15 +27,22 @@ async function holdBranchSuggestions(page: Page) { await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { const server = browser.connectToServer(); browser.onMessage((message) => { - const envelope = JSON.parse(message.toString()); - if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request") - requestCount++; + if (typeof message === "string") { + const envelope = JSON.parse(message); + if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request") + requestCount++; + } server.send(message); }); server.onMessage(async (message) => { - const envelope = JSON.parse(message.toString()); - if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") { - await gate; + if (typeof message === "string") { + const envelope = JSON.parse(message); + if ( + envelope.type === "session" && + envelope.message?.type === "branch_suggestions_response" + ) { + await gate; + } } browser.send(message); }); diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 7137c903ff..026e2791c9 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -8,7 +8,11 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { connectSeedClient } from "../support/helpers/seed-client"; import { createTempGitRepo } from "../support/helpers/workspace"; -import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { + openChangesPanel, + openChangesTreePanel, + waitForWorkspaceTabsVisible, +} from "../support/helpers/workspace-tabs"; interface DirtyWorkspace { id: string; @@ -262,6 +266,126 @@ test.afterEach(async () => { } }); +test("Changes without a comparison base explains how to view pending changes", async ({ page }) => { + const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev"); + await openWorkspaceChangesTree(page, workspace); + await expect( + page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."), + ).toBeVisible(); + await selectChangesComparison(page, "Uncommitted"); + await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible(); +}); +async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); + await waitForWorkspaceTabsVisible(page); + await openChangesTreePanel(page); +} + +test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const workspace = await createLargeStreamedWorkspace(); + await addOversizedSibling(workspace); + const stream = await holdDiffCompletion(page); + await openWorkspaceChangesSurface(page, workspace, 90_000); + await expectAnnouncedTreeAndLoader(page); + await page.screenshot({ path: testInfo.outputPath("tree-loading.png") }); + stream.finish(); + await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0); + await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0); + const connections = stream.connections; + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1"); + await addOneLineToFirstFile(workspace); + await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1); + await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1"); + await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0); + expect(stream.connections).toBe(connections); + await page.screenshot({ path: testInfo.outputPath("refreshed.png") }); +}); + +async function createLargeStreamedWorkspace(): Promise { + const workspace = await createWorkspaceWithManyTinyDiffs(252); + await Promise.all( + Array.from({ length: 252 }, (_, file) => + writeFile( + path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`), + Array.from( + { length: 90 }, + (_unused, line) => + `export const fn${line} = (param: string) => ({ key: param, value: ${file} });`, + ).join("\n") + "\n", + ), + ), + ); + return workspace; +} +async function addOversizedSibling(workspace: DirtyWorkspace): Promise { + await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1)); +} +async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise { + const file = path.join(workspace.repoPath, "src/file-0000.ts"); + await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n"); +} +async function holdDiffCompletion(page: Page) { + const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index"); + let holding = true; + let connections = 0; + let completed = 0; + const completions: Array<() => void> = []; + const changedBodies: number[] = []; + const bodyCounts = new Map(); + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + connections++; + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => serverSocket.send(message)); + serverSocket.onMessage((message) => { + if ( + typeof message !== "string" && + message[0] >= DiffOpcode.Begin && + message[0] <= DiffOpcode.End + ) { + const frame = decodeDiffFrame(message); + if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0); + else if (frame.opcode === DiffOpcode.Bodies) + bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length); + else { + completed++; + changedBodies.push(bodyCounts.get(frame.slot) ?? 0); + } + } + if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) { + completions.push(() => browserSocket.send(message)); + return; + } + browserSocket.send(message); + }); + }); + return { + changedBodies, + get connections() { + return connections; + }, + get completed() { + return completed; + }, + finish: () => { + holding = false; + completions.splice(0).forEach((send) => send()); + }, + }; +} +async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise { + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout }); +} +async function expectAnnouncedTreeAndLoader(page: Page): Promise { + await expect(changesTree(page).first()).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible(); +} + test("Changes opens All changes for a clean checkout", async ({ page }) => { const workspace = await createWorkspaceWithCommittedDiff(); @@ -499,6 +623,35 @@ test("an empty Changes comparison links to the populated comparison", async ({ p await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0"); }); +test("legacy diff hosts keep All, Committed and Uncommitted comparisons", async ({ page }) => { + await useLegacyDiffHost(page); + const workspace = await createWorkspaceWithScrollableComparisons(); + await openWorkspaceChangesSurface(page, workspace); + await expect(changesTree(page).getByText("committed", { exact: true })).toBeVisible(); + await expect(changesTree(page).getByText("uncommitted", { exact: true })).toBeVisible(); + await selectChangesComparison(page, "Committed"); + await expectWorkingComparisonFiles(page, "committed"); + await selectChangesComparison(page, "Uncommitted"); + await expectWorkingComparisonFiles(page, "uncommitted"); +}); +async function useLegacyDiffHost(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => serverSocket.send(message)); + serverSocket.onMessage((message) => { + if (typeof message === "string") { + const envelope = JSON.parse(message); + if (envelope.message?.payload?.status === "server_info") { + delete envelope.message.payload.features.diffSubscription; + browserSocket.send(JSON.stringify(envelope)); + return; + } + } + browserSocket.send(message); + }); + }); +} + test("Changes comparison controls the working diff and tree selection focuses its file", async ({ page, }) => { @@ -1311,11 +1464,13 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({ page, }) => { - test.setTimeout(180_000); + test.setTimeout(300_000); const workspace = await createWorkspaceWithManyTinyDiffs(2_000); await useUnwrappedDiffLines(page); await openWorkspaceChangesSurface(page, workspace, 90_000); + // Measure document bounds after delivery, independently of the cold Git read. + await expectWorkingDiffLoaded(page, 240_000); const root = page.getByTestId("git-diff-canvas-root"); const scroller = page.getByTestId("git-diff-scroll"); await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2); @@ -1894,12 +2049,16 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise { return { id: created.workspace.id, repoPath: repo.path }; } -async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise { +async function createWorkspaceWithManyTinyDiffs( + fileCount: number, + branch = "main", +): Promise { const files = Array.from({ length: fileCount }, (_, index) => ({ path: `src/file-${String(index).padStart(4, "0")}.ts`, content: "export const value = 0;\n", })); const repo = await createTempGitRepo("changes-canvas-many-files-", { files }); + if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path }); await Promise.all( files.map((file, index) => writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`), diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 4f2610b8db..61d2a5a35e 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -19,6 +19,7 @@ import { expectWorkspaceHeaderVisible, resizeComposerViewport, submitMessage, + submitMessageWithButton, typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -146,6 +147,14 @@ test("submitting a message leaves the composer ready for the next message", asyn await openAgentRoute(page, agent); await expectComposerVisible(page); + await submitMessageWithButton(page, "Pointer submission"); + await expectComposerDraft(page, ""); + await expectComposerFocused(page); + await typeIntoFocusedComposer(page, "Ready after Send"); + await expectComposerDraft(page, "Ready after Send"); + await agent.client.waitForFinish(agent.agentId, 15_000); + await expectComposerFocused(page); + await submitMessage(page, "First message"); await expectComposerFocused(page); @@ -173,7 +182,7 @@ test("submitting a message leaves the composer ready for the next message", asyn "Investigate: [this FR](https://example.com/issues/39150) next. Thanks", ); await agent.client.waitForFinish(agent.agentId, 15_000); - await expect(page.getByRole("button", { name: "Copy turn", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible(); await connection.drop(); await connection.waitForBlockedConnection(); await pasteRichTextIntoComposer({ @@ -313,6 +322,9 @@ test.describe("touch screen wide enough for the desktop layout", () => { // Autofocus makes its first attempt on the next animation frame. await waitForAnimationFrames(page, 4); await expectComposerNotFocused(page); + await submitMessageWithButton(page, "Touch submission"); + await expectComposerDraft(page, ""); + await expectComposerNotFocused(page); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/direct-terminal-shortcut-pane-placement.spec.ts b/packages/app/e2e/browser/direct-terminal-shortcut-pane-placement.spec.ts index 373459ef4c..b49e89cde7 100644 --- a/packages/app/e2e/browser/direct-terminal-shortcut-pane-placement.spec.ts +++ b/packages/app/e2e/browser/direct-terminal-shortcut-pane-placement.spec.ts @@ -51,7 +51,7 @@ test.describe("Direct terminal shortcut pane placement", () => { }); }); -async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) { +async function prepareNarrowSplit(page: Page, workspaceId: string) { await page.setViewportSize({ width: 1600, height: 900 }); await gotoWorkspace(page, workspaceId); await waitForWorkspaceTabsVisible(page); @@ -74,33 +74,38 @@ async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) { }; } -async function launchTerminalFromCompactPane(page: Page, pane: Locator) { - await pane.getByTestId("workspace-tab-switcher-trigger").click(); - await page.getByTestId("workspace-tab-switcher-new-terminal").click(); +async function expectPanesKeepTabRails(page: Page, panes: Locator[]) { + for (const pane of panes) { + await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible(); + } + await expect( + page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }), + ).toHaveCount(0); +} + +async function launchTerminalFromPaneRail(page: Page, pane: Locator) { + await pane.getByTestId("workspace-new-tab-button").click(); + await page.getByTestId("workspace-new-tab-menu-terminal").filter({ visible: true }).click(); await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 }); } -async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) { - await pane.getByTestId("workspace-header-menu-trigger").click(); - await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible(); - await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click(); - await pane.getByTestId("workspace-header-menu-trigger").click(); - await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click(); +async function maximizeAndRestorePane(pane: Locator) { + await pane.getByTestId("workspace-maximize-pane").click(); + await pane.getByTestId("workspace-restore-pane").click(); } -test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({ +test("narrow split panes keep their tab rails and launches stay in the owning pane", async ({ page, }) => { - const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" }); + const workspace = await seedWorkspace({ repoPrefix: "narrow-split-launch-" }); try { - const { originalPane, otherPane } = await prepareUnfocusedCompactPane( - page, - workspace.workspaceId, - ); - await launchTerminalFromCompactPane(page, originalPane); + const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId); + await expectPanesKeepTabRails(page, [originalPane, otherPane]); + await launchTerminalFromPaneRail(page, originalPane); await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible(); - await maximizeAndRestoreCompactPane(page, originalPane); + await maximizeAndRestorePane(originalPane); await expect(otherPane).toBeVisible(); + await expectPanesKeepTabRails(page, [originalPane, otherPane]); } finally { await workspace.cleanup(); } @@ -118,17 +123,15 @@ async function expectPaneHasWindowDragSurface(pane: Locator) { .toBe(true); } -test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" }); +test("narrow desktop split panes retain window dragging with both sidebars closed", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "narrow-split-window-drag-" }); try { await installLocalDesktopHost(page, workspace.repoPath); - const { originalPane, otherPane } = await prepareUnfocusedCompactPane( - page, - workspace.workspaceId, - ); + const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId); await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar"); - await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible(); - await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible(); + await expectPanesKeepTabRails(page, [originalPane, otherPane]); await expectPaneHasWindowDragSurface(originalPane); await expectPaneHasWindowDragSurface(otherPane); } finally { diff --git a/packages/app/e2e/browser/explorer-resize-performance.spec.ts b/packages/app/e2e/browser/explorer-resize-performance.spec.ts index 33cf48c94c..ca2ec5bf13 100644 --- a/packages/app/e2e/browser/explorer-resize-performance.spec.ts +++ b/packages/app/e2e/browser/explorer-resize-performance.spec.ts @@ -79,6 +79,12 @@ async function previewExplorerResize(page: Page, distance: number) { return { ...measurement, width: after }; } +async function expectCleanChangesLoaded(page: Page): Promise { + await expect( + page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }), + ).toBeVisible(); +} + async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise { await page.reload(); const explorer = page.getByTestId("workspace-explorer-sidebar"); @@ -100,6 +106,7 @@ test("Explorer drag previews without rerendering panes or rewriting the shared s await runWorkspaceActionFromCommandCenter(page, "Split pane right"); await page.getByTestId("workspace-explorer-toggle").first().click(); await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + await expectCleanChangesLoaded(page); // Verify that direct pane renders are being counted before resetting them. expect( await page.evaluate(() => diff --git a/packages/app/e2e/browser/new-workspace-live-pin.spec.ts b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts index 3ed0755fc9..135e9439f8 100644 --- a/packages/app/e2e/browser/new-workspace-live-pin.spec.ts +++ b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts @@ -1,6 +1,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; +import { expectComposerModel } from "../support/helpers/agent-profiles"; import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer, @@ -69,6 +70,8 @@ test.describe("New workspace live pin", () => { await gotoAppShell(page); await waitForSidebarHydration(page); await openNewWorkspaceComposer(page, seeded); + // The composer appears before its provider catalog has hydrated. + await expectComposerModel(page, "Ten second stream"); await submitNewWorkspacePrompt(page, "Pin me while I start"); await creation.waitForCreateRequest(); diff --git a/packages/app/e2e/browser/sessions-search.spec.ts b/packages/app/e2e/browser/sessions-search.spec.ts index 7f54617304..6cf8024e35 100644 --- a/packages/app/e2e/browser/sessions-search.spec.ts +++ b/packages/app/e2e/browser/sessions-search.spec.ts @@ -60,6 +60,15 @@ async function verifyChronologicalFiltering(page: Page): Promise { }); } +async function verifyHistorySurvivesReload(page: Page): Promise { + await test.step("reloads History without visiting a workspace to reconnect", async () => { + await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]); + await page.reload(); + await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]); + await expect(page.getByTestId("sessions-host-errors")).toBeHidden(); + }); +} + async function verifyRecencyBeforeMatchStrength(page: Page): Promise { await test.step("keeps newer partial matches before older stronger matches", async () => { await search(page, `${NONCE} bill`); @@ -151,6 +160,8 @@ test.describe("History search", () => { await verifyChronologicalFiltering(page); + await verifyHistorySurvivesReload(page); + await verifyRecencyBeforeMatchStrength(page); await verifyExactAndTypoHighlights(page); diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index 4d77fde7d8..5b019139f2 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -95,66 +95,63 @@ test.describe("Settings sidebar scroll position", () => { await expectSettingsSidebarScrollOffset(page, offset); }); - test("scrolls the sidebar and detail pane independently", async ({ page }) => { - await gotoAppShell(page); - await openSettings(page); - const sidebarOffset = await scrollSettingsSidebarToEnd(page); - await openSettingsHostSection(page, getServerId(), "providers"); - await expect(page.getByTestId("host-page-providers-card")).toBeVisible(); - const detailOffset = await scrollSettingsDetailDown(page); - await expectSettingsSidebarScrollOffset(page, sidebarOffset); - - await scrollSettingsSidebarUp(page, 60); - await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60); - await expectSettingsDetailScrollOffset(page, detailOffset); - }); - - test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => { - await gotoAppShell(page); - await openSettings(page); - - const scrollTop = await scrollSettingsSidebarToEnd(page); - - await selectSettingsHostSectionsRapidly(page); - await expectSettingsSidebarScrollOffset(page, scrollTop); - - await expectSettingsSidebarScrollSettled(page, scrollTop); - }); - - test("keeps the sidebar position when switching between app and host sections", async ({ + test("keeps sidebar and detail scrolling independent through section changes", async ({ page, }) => { await gotoAppShell(page); await openSettings(page); - const hostScrollTop = await scrollSettingsSidebarToEnd(page); - await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport(); + await test.step("scrolls the sidebar and detail pane independently", async () => { + const sidebarOffset = await scrollSettingsSidebarToEnd(page); + await openSettingsHostSection(page, getServerId(), "providers"); + await expect(page.getByTestId("host-page-providers-card")).toBeVisible(); + const detailOffset = await scrollSettingsDetailDown(page); + await expectSettingsSidebarScrollOffset(page, sidebarOffset); - for (const section of [ - "providers", - "terminals", - "providers", - "terminals", - "providers", - ] as const) { - await openSettingsHostSection(page, getServerId(), section); - await expect(page.getByTestId("page-title")).toHaveText( - section === "providers" ? "Providers" : "Terminals", - ); - await expectSettingsSidebarScrollOffset(page, hostScrollTop); - } + await scrollSettingsSidebarUp(page, 60); + await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60); + await expectSettingsDetailScrollOffset(page, detailOffset); + }); - const sidebar = page.getByTestId("settings-sidebar"); - const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics"); - await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport(); + await test.step("keeps the sidebar position after rapid consecutive selections", async () => { + const scrollTop = await scrollSettingsSidebarToEnd(page); - await openSettingsSection(page, "about"); - await expectAboutContent(page); - await expectSettingsSidebarScrollOffset(page, appScrollTop); + await selectSettingsHostSectionsRapidly(page); + await expectSettingsSidebarScrollOffset(page, scrollTop); - await openSettingsSection(page, "diagnostics"); - await expectDiagnosticsContent(page); - await expectSettingsSidebarScrollOffset(page, appScrollTop); + await expectSettingsSidebarScrollSettled(page, scrollTop); + }); + + await test.step("keeps the sidebar position when switching between app and host sections", async () => { + const hostScrollTop = await scrollSettingsSidebarToEnd(page); + await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport(); + + for (const section of [ + "providers", + "terminals", + "providers", + "terminals", + "providers", + ] as const) { + await openSettingsHostSection(page, getServerId(), section); + await expect(page.getByTestId("page-title")).toHaveText( + section === "providers" ? "Providers" : "Terminals", + ); + await expectSettingsSidebarScrollOffset(page, hostScrollTop); + } + + const sidebar = page.getByTestId("settings-sidebar"); + const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics"); + await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport(); + + await openSettingsSection(page, "about"); + await expectAboutContent(page); + await expectSettingsSidebarScrollOffset(page, appScrollTop); + + await openSettingsSection(page, "diagnostics"); + await expectDiagnosticsContent(page); + await expectSettingsSidebarScrollOffset(page, appScrollTop); + }); }); }); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index a7a7b4320a..a307002c3b 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -6,6 +6,7 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, + expectOnlyWorkspaceHoverCardFor, expectWorkspaceHoverCardClosed, expectWorkspaceHoverCardOpen, expectWorkspaceHoverCardStaysOpen, @@ -14,6 +15,7 @@ import { openMobileAgentSidebar, pinWorkspaceFromSidebar, scrollWorkspaceList, + sweepPointerAcrossWorkspaceRows, } from "../support/helpers/sidebar"; import { seedWorkspace } from "../support/helpers/seed-client"; import { expectWorkspaceHeader } from "../support/helpers/workspace-ui"; @@ -80,6 +82,22 @@ async function openWorkspaceHoverCard(page: import("@playwright/test").Page, wor return hoverCard; } +async function sortWorkspacesBySidebarOrder( + page: Page, + workspaces: T[], +): Promise { + const positioned = await Promise.all( + workspaces.map(async (workspace) => { + const bounds = await page + .getByTestId(getWorkspaceRowTestId(workspace.workspaceId)) + .boundingBox(); + if (!bounds) throw new Error(`Could not measure the row of ${workspace.workspaceId}`); + return { workspace, y: bounds.y }; + }), + ); + return positioned.sort((left, right) => left.y - right.y).map(({ workspace }) => workspace); +} + interface PaseoOwnedWorktree { projectName: string; workspaceId: string; @@ -430,6 +448,38 @@ test.describe("Sidebar workspace list", () => { } }); + test("scanning up the workspace rows leaves only the resting row's hover card open", async ({ + page, + }) => { + const workspaces = await Promise.all( + [1, 2, 3, 4, 5].map((index) => + seedWorkspace({ + repoPrefix: `sidebar-hover-scan-${index}-`, + title: `Hover scan ${index}`, + }), + ), + ); + + try { + await gotoAppShell(page); + for (const workspace of workspaces) { + await waitForSidebarWorkspace(page, workspace.workspaceId); + } + const rowsTopToBottom = await sortWorkspacesBySidebarOrder(page, workspaces); + const top = rowsTopToBottom[0]!; + + for (let scan = 0; scan < 3; scan += 1) { + await sweepPointerAcrossWorkspaceRows( + page, + rowsTopToBottom.map((workspace) => workspace.workspaceId).toReversed(), + ); + await expectOnlyWorkspaceHoverCardFor(page, top.workspaceName); + } + } finally { + await Promise.all(workspaces.map((workspace) => workspace.cleanup())); + } + }); + test("workspace hover card follows keyboard focus on a row", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" }); diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index bcfca2f763..1ae180fb8e 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -63,6 +63,26 @@ async function rowsBetweenLatestSummaryAndReply( }, replyTestId); } +/** The latest turn's summary line is a chat row of its own, sitting apart from its prompt. */ +async function expectSummaryLineSitsApartFromPrompt(page: Page) { + const summaryRow = () => + page.evaluate(() => { + const latest = (testId: string) => + [...document.querySelectorAll(`[data-testid="${testId}"]`)].at(-1)!; + const summary = latest("turn-summary"); + const row = summary.closest("[data-message-id]")!; + return { + sharesRowWithPrompt: row.querySelector("[data-testid=user-message]") !== null, + gapBelowPrompt: Math.round( + summary.getBoundingClientRect().top - + latest("user-message").getBoundingClientRect().bottom, + ), + }; + }); + await expect.poll(summaryRow).toMatchObject({ sharesRowWithPrompt: false }); + expect((await summaryRow()).gapBelowPrompt).toBeGreaterThan(0); +} + async function groupedToolsInsideLatestTurn(page: Page): Promise { return page.evaluate(() => { const rows = [...document.querySelectorAll("[data-message-id]")]; @@ -208,6 +228,7 @@ export default function contribute(client) { /\(end of synthetic stream\)$/, ); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await expectSummaryLineSitsApartFromPrompt(page); await summary.click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0); @@ -470,34 +491,87 @@ async function seedWholeTurnHistory() { return agent; } -test("loads complete long turns and preserves expansion while revealing older history", async ({ - page, -}) => { +async function expectInitialFillIsConcealed(page: Page) { + await expect(page.getByTestId("agent-initial-fill")).toBeVisible(); + await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0"); +} + +async function expectFilledChatAtBottom(page: Page) { + await expect(page.getByTestId("agent-initial-fill")).toBeHidden(); + const chat = page.getByTestId("agent-chat-scroll"); + await expect(chat).toHaveCSS("opacity", "1"); + await expect + .poll(() => + chat.evaluate((element) => ({ + hasOverflow: element.scrollHeight > element.clientHeight, + atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2, + })), + ) + .toEqual({ hasOverflow: true, atBottom: true }); +} + +test("fills collapsed history before revealing a fresh chat", async ({ page }) => { test.setTimeout(120_000); + await page.setViewportSize({ width: 1280, height: 1000 }); await useChatDisplay(page, "collapseTurns"); const agent = await seedWholeTurnHistory(); try { const history = await holdOlderHistoryPages(page, agent); await openAgentRoute(page, agent); + await history.expectRequestedPages(1); + await expectInitialFillIsConcealed(page); + history.releasePage(1); + await history.expectRequestedPages(2); + await expectInitialFillIsConcealed(page); + history.releasePage(2); + await expectFilledChatAtBottom(page); + await page.setViewportSize({ width: 1280, height: 8000 }); + await history.expectRequestedPages(3); + await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1"); + await expect(page.getByTestId("agent-initial-fill")).toBeHidden(); + history.releasePage(3); + await history.expectSettledWithRequestedPages(3); + history.expectNoRepeatedEntries(); + } finally { + await agent.cleanup(); + } +}); + +test("loads complete long turns and preserves expansion while revealing older history", async ({ + page, +}) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 1280, height: 1000 }); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedWholeTurnHistory(); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentRoute(page, agent); + await history.expectRequestedPages(1); + history.releasePage(1); + await history.expectRequestedPages(2); + history.releasePage(2); + await expectFilledChatAtBottom(page); await expectComposerVisible(page); const latest = page .getByTestId("turn-summary") .getByRole("button", { name: "Ran 120 commands", exact: true }); await expect(latest).toBeVisible(); - await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); - await latest.click(); - await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); - await userScrollsTimelineToHistoryStart(page); - await history.expectRequestedPages(1); - history.releasePage(1); await expect( page .getByTestId("turn-summary") .getByRole("button", { name: "Ran 60 commands", exact: true }), ).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await latest.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(3); + history.releasePage(3); + await page.getByTestId("scroll-to-bottom-button").click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); history.expectNoRepeatedEntries(); - await latest.scrollIntoViewIfNeeded(); + await scrollTimelineRowIntoView(page, latest); await latest.click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); } finally { diff --git a/packages/app/e2e/browser/workspace-split-header.spec.ts b/packages/app/e2e/browser/workspace-split-header.spec.ts new file mode 100644 index 0000000000..27bb92584d --- /dev/null +++ b/packages/app/e2e/browser/workspace-split-header.spec.ts @@ -0,0 +1,131 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; + +function visible(page: Page, testId: string): Locator { + return page.getByTestId(testId).filter({ visible: true }); +} + +function panes(page: Page): Locator { + return page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true }); +} + +function splitDividers(page: Page): Locator { + return visible(page, "workspace-split-resize-handle"); +} + +async function openSplitWorkspace( + page: Page, + repoPrefix: string, + split: "Split pane right" | "Split pane down", +): Promise { + await page.setViewportSize({ width: 1600, height: 900 }); + const workspace = await seedWorkspace({ repoPrefix }); + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + await clickNewChat(page); + await expect(visible(page, "workspace-header-title")).toHaveCount(1); + await runWorkspaceActionFromCommandCenter(page, split); + await expect(splitDividers(page)).toHaveCount(1); + await expect(panes(page)).toHaveCount(2); + return workspace; +} + +async function expectOneSharedWorkspaceHeader(page: Page): Promise { + await expect(visible(page, "workspace-header-title")).toHaveCount(1); + await expect(visible(page, "workspace-header-menu-trigger")).toHaveCount(1); + await expect(panes(page).getByTestId("workspace-header-title")).toHaveCount(0); + await expect(panes(page).getByTestId("workspace-header-menu-trigger")).toHaveCount(0); +} + +/** The tab counter belongs to the single-pane layout: split panes always show their rails. */ +async function expectPanesKeepTabRails(page: Page): Promise { + for (const pane of await panes(page).all()) { + await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible(); + } + await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(0); +} + +async function dragDivider(divider: Locator, deltaX: number): Promise { + const page = divider.page(); + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + deltaX, y, { steps: 8 }); + await page.mouse.up(); +} + +test.describe("Workspace header with split panes", () => { + test("wide side-by-side panes share one header and keep their tab rails", async ({ page }) => { + const workspace = await openSplitWorkspace(page, "split-header-wide-", "Split pane right"); + try { + await expectOneSharedWorkspaceHeader(page); + await expectPanesKeepTabRails(page); + } finally { + await workspace.cleanup(); + } + }); + + test("stacked panes share one header", async ({ page }) => { + const workspace = await openSplitWorkspace(page, "split-header-stacked-", "Split pane down"); + try { + await expectOneSharedWorkspaceHeader(page); + await expectPanesKeepTabRails(page); + } finally { + await workspace.cleanup(); + } + }); + + test("a narrow workspace shows one compact header and every pane keeps its tab rail", async ({ + page, + }) => { + const workspace = await openSplitWorkspace(page, "split-header-narrow-", "Split pane right"); + try { + await page.setViewportSize({ width: 760, height: 900 }); + await expectPanesKeepTabRails(page); + await expectOneSharedWorkspaceHeader(page); + } finally { + await workspace.cleanup(); + } + }); + + test("a narrow pane beside a wide one keeps its tab rail", async ({ page }) => { + const workspace = await openSplitWorkspace(page, "split-header-mixed-", "Split pane right"); + try { + await dragDivider(splitDividers(page), -300); + const [left, right] = await panes(page).all(); + const leftBox = await left.boundingBox(); + const rightBox = await right.boundingBox(); + expect(leftBox!.width).toBeLessThan(500); + expect(rightBox!.width).toBeGreaterThan(500); + await expectPanesKeepTabRails(page); + await expectOneSharedWorkspaceHeader(page); + } finally { + await workspace.cleanup(); + } + }); + + test("closing back to one pane returns the merged compact header", async ({ page }) => { + const workspace = await openSplitWorkspace(page, "split-header-close-", "Split pane right"); + try { + const [, emptyPane] = await panes(page).all(); + const emptyPaneRail = emptyPane.getByTestId("workspace-tabs-row"); + await emptyPaneRail.locator('[data-testid^="workspace-tab-"]').first().hover(); + await emptyPaneRail.locator('[data-testid*="-close-"]').first().click(); + await expect(splitDividers(page)).toHaveCount(0); + await expect(visible(page, "workspace-header-title")).toHaveCount(1); + await page.setViewportSize({ width: 760, height: 900 }); + await expect(visible(page, "workspace-tabs-row")).toHaveCount(0); + await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(1); + await expect(visible(page, "workspace-header-title")).toHaveCount(1); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/archive-tab.ts b/packages/app/e2e/support/helpers/archive-tab.ts index cf9c43fcf3..97291d8951 100644 --- a/packages/app/e2e/support/helpers/archive-tab.ts +++ b/packages/app/e2e/support/helpers/archive-tab.ts @@ -219,6 +219,11 @@ export async function expectWorkspaceArchiveOutcome( await expectWorkspaceTabVisible(page, input.survivingAgentId); } +export async function closeActiveAgentWithShortcut(page: Page, agentId: string): Promise { + await page.keyboard.press("Alt+Shift+w"); + await expectWorkspaceTabHidden(page, agentId); +} + export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise { const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({ visible: true, @@ -237,6 +242,10 @@ export async function expectArchivedAgentFocused(page: Page, agentId: string): P }); } +export async function unarchiveAgentFromConversation(page: Page): Promise { + await page.getByRole("button", { name: "Unarchive", exact: true }).click(); +} + export async function reloadWorkspace(page: Page, workspaceId: string): Promise { const serverId = getServerId(); await page.goto(buildHostWorkspaceRoute(serverId, workspaceId)); diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 6e0a4abedc..96d1441070 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -96,6 +96,35 @@ export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise { + const centers: Array<{ x: number; y: number }> = []; + for (const workspaceId of workspaceIds) { + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + const bounds = await row.boundingBox(); + if (!bounds) throw new Error(`Could not measure the row of ${workspaceId}`); + centers.push({ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }); + } + const [first, ...rest] = centers; + await page.mouse.move(first!.x, first!.y); + for (const center of rest) { + await page.mouse.move(center.x, center.y, { steps: 3 }); + } +} + +export async function expectOnlyWorkspaceHoverCardFor( + page: Page, + workspaceName: string, +): Promise { + const cards = page.getByTestId("workspace-hover-card"); + await expect(cards).toHaveCount(1); + await expect(cards.getByTestId("hover-card-workspace-name")).toHaveText(workspaceName); +} + export async function expectWorkspaceHoverCardClosed(page: Page): Promise { await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0); } diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index f54fd86812..ed69897e4e 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -575,8 +575,10 @@ export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: stri const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt }); await expect(promptRow).toBeVisible({ timeout: 15_000 }); - // The host row carries the folded turn's summary line. - await expect(promptRow.getByTestId("turn-summary")).toBeVisible(); + // The folded turn's summary line is the row under its prompt. + const hostId = await promptRow.getAttribute("data-history-row-id"); + const summaryRow = timeline.locator(`[data-history-row-id="turn-summary:${hostId}"]`); + await expect(summaryRow.getByTestId("turn-summary")).toBeVisible(); await expect .poll( async () => { diff --git a/packages/app/src/agent-stream/disclosure.browser.test.tsx b/packages/app/src/agent-stream/disclosure.browser.test.tsx new file mode 100644 index 0000000000..74c61c4988 --- /dev/null +++ b/packages/app/src/agent-stream/disclosure.browser.test.tsx @@ -0,0 +1,147 @@ +import React, { act, createRef, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { within } from "@testing-library/dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n as testI18n } from "@/i18n/i18next"; +import { PlanCard } from "@/components/plan-card"; +import { MessagePreview } from "@/components/user-message"; +import { DisclosureRow } from "./disclosure"; +import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy"; + +// Load translations so the toggles expose their real accessible names. +void testI18n; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +let root: Root; +let container: HTMLDivElement; +// The plan body renders through the app's Markdown renderer, which reads through React Query. +let queryClient: QueryClient; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient(); +}); + +afterEach(() => { + act(() => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +interface Hold { + rowId: string; + triggerEdge: DisclosureTriggerEdge; + /** Whether the expanded content was already on screen when the strategy received the hold. */ + appliedBefore: boolean; +} + +/** + * A timeline row whose viewport records each hold. Like the strategies, it runs `apply` itself, so + * a toggle that skips the hold still changes state but leaves no record. + */ +function mountRow(rowId: string, isApplied: () => boolean, children: ReactNode) { + const holds: Hold[] = []; + const viewport: StreamViewportHandle = { + scrollToBottom: vi.fn(), + prepareForViewportChange: vi.fn(), + holdRow: (heldRowId, apply, triggerEdge) => { + holds.push({ rowId: heldRowId, triggerEdge, appliedBefore: isApplied() }); + apply(); + }, + }; + const viewportRef = createRef(); + viewportRef.current = viewport; + act(() => + root.render( + + + {children} + + , + ), + ); + return holds; +} + +const view = () => within(container); + +const preLine = { whiteSpace: "pre-line" } as const; + +async function waitForLayout() { + await act(async () => { + for (let frame = 0; frame < 2; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }); +} + +describe("timeline disclosures hold their row", () => { + it("routes user message show more and show less through the row's hold", async () => { + const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n"); + const holds = mountRow( + "message-1", + () => view().queryByRole("button", { name: "Show less" }) !== null, + + {longText} + , + ); + // The preview learns it overflows from its first layout. + await waitForLayout(); + + act(() => view().getByRole("button", { name: "Show more" }).click()); + expect(view().getByRole("button", { name: "Show less" })).toBeTruthy(); + + act(() => view().getByRole("button", { name: "Show less" }).click()); + expect(view().getByRole("button", { name: "Show more" })).toBeTruthy(); + + expect(holds).toEqual([ + // Show more sits under the text it opens. + { rowId: "message-1", triggerEdge: "bottom", appliedBefore: false }, + { rowId: "message-1", triggerEdge: "bottom", appliedBefore: true }, + ]); + }); + + it("routes plan card collapse and expand through the row's hold", () => { + const header = () => view().getByRole("button", { name: "Approved plan" }); + const isExpanded = () => header().getAttribute("aria-expanded") === "true"; + const holds = mountRow( + "plan-1", + isExpanded, + , + ); + + act(() => header().click()); + expect(isExpanded()).toBe(false); + + act(() => header().click()); + expect(isExpanded()).toBe(true); + + expect(holds).toEqual([ + { rowId: "plan-1", triggerEdge: "top", appliedBefore: true }, + { rowId: "plan-1", triggerEdge: "top", appliedBefore: false }, + ]); + }); + + it("toggles a plan card outside a timeline row, as the pending permission card renders", () => { + act(() => + root.render( + + + , + ), + ); + const header = () => view().getByRole("button", { name: "Plan" }); + const isExpanded = () => header().getAttribute("aria-expanded") === "true"; + + act(() => header().click()); + expect(isExpanded()).toBe(false); + + act(() => header().click()); + expect(isExpanded()).toBe(true); + }); +}); diff --git a/packages/app/src/agent-stream/disclosure.tsx b/packages/app/src/agent-stream/disclosure.tsx index b35e67e567..2deb42554f 100644 --- a/packages/app/src/agent-stream/disclosure.tsx +++ b/packages/app/src/agent-stream/disclosure.tsx @@ -1,11 +1,15 @@ import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react"; -import type { StreamViewportHandle } from "./strategy"; +import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy"; /** - * Runs a disclosure's state change. Inside a timeline row, the row that owns the - * trigger keeps its screen position through the change; elsewhere `apply` runs as is. + * Runs a disclosure's state change. Inside a timeline row, the trigger keeps its screen + * position through the change; elsewhere `apply` runs as is. A trigger below the content it + * opens passes `triggerEdge: "bottom"`. */ -export type DisclosureHold = (apply: () => void) => void; +export type DisclosureHold = ( + apply: () => void, + options?: { triggerEdge?: DisclosureTriggerEdge }, +) => void; const applyInPlace: DisclosureHold = (apply) => apply(); @@ -21,10 +25,10 @@ export function DisclosureRow({ children: ReactNode; }) { const hold = useMemo( - () => (apply) => { + () => (apply, options) => { const viewport = viewportRef.current; if (viewport) { - viewport.holdRow(rowId, apply); + viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top"); } else { apply(); } diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts index 9663d87e23..4867bcce2e 100644 --- a/packages/app/src/agent-stream/history-start-pagination.test.ts +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -1,197 +1,310 @@ -import { describe, expect, it } from "vitest"; +import { nativeHistoryStartGeometry } from "./native-history-start-geometry"; +import { describe, expect, it, vi } from "vitest"; import { - abandonHistoryStartPaginationRequest, - createHistoryStartPaginationState, - evaluateHistoryStartPagination, - isHistoryStartLoadingOperation, - rearmHistoryStartPagination, - settleHistoryStartPagination, + createHistoryStartPagination, type HistoryStartPaginationInput, } from "./history-start-pagination"; -function createArmedHistoryStartPaginationState() { - return rearmHistoryStartPagination(createHistoryStartPaginationState()); -} - -const visibleHistoryStart: HistoryStartPaginationInput = { +const initial: HistoryStartPaginationInput = { distanceFromHistoryStart: 0, + isUnderfilled: true, hasOlderHistory: true, isLoadingOlderHistory: false, isReady: true, - progressKey: "epoch-1:20", + progressKey: "20", }; +function harness(initiallyVisible = false) { + let geometry = { ...initial }; + const requests: Array<(value: boolean) => void> = []; + let settles = 0; + const pagination = createHistoryStartPagination({ + initiallyVisible, + read: () => geometry, + load: () => new Promise((resolve) => requests.push(resolve)), + changed: () => {}, + scheduleSettle: () => { + settles++; + }, + }); + return { + pagination, + requests, + get settles() { + return settles; + }, + set(patch: Partial) { + geometry = { ...geometry, ...patch }; + pagination.update(); + }, + }; +} + describe("history start pagination", () => { - it("treats locally revealed history as progress before remote pagination", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const localReveal = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:20:local-60", - }); - - expect(first.shouldLoad).toBe(true); - expect(localReveal.state).toEqual({ - status: "settling", - loadedProgressKey: "epoch-1:20:local-60", - }); + it("waits for a delayed request and its committed page before settling", async () => { + const h = harness(); + h.pagination.update(); + h.set({ progressKey: "10" }); + expect(h.settles).toBe(0); + h.requests[0](true); + await Promise.resolve(); + expect(h.settles).toBe(1); + expect(h.pagination.state.visible).toBe(false); + h.set({ isUnderfilled: false }); + h.pagination.settle(); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); }); - it("waits for user scroll intent before loading", () => { - const dormant = createHistoryStartPaginationState(); - const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart); - - expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false }); + it("waits for the committed page after a request completes", async () => { + const h = harness(); + h.pagination.update(); + h.requests[0](true); + await Promise.resolve(); + expect(h.settles).toBe(0); + h.set({ progressKey: "10" }); + expect(h.settles).toBe(1); + h.pagination.dispose(); }); - - it("waits for anchored page geometry before authorizing another page", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const pageApplied = evaluateHistoryStartPagination(inFlight.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - progressKey: "epoch-1:10", - }); - - expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([ - true, - false, - false, - ]); - expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" }); + it("continues short pages and reveals on exhaustion", async () => { + const h = harness(); + h.pagination.update(); + h.set({ progressKey: "10" }); + h.requests[0](true); + await Promise.resolve(); + h.pagination.settle(); + expect(h.requests).toHaveLength(2); + h.set({ hasOlderHistory: false }); + h.requests[1](true); + await Promise.resolve(); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); }); - - it("loads one page each time anchored geometry leaves and returns to history start", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const pageApplied = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - const settledAway = settleHistoryStartPagination(pageApplied.state, { - ...visibleHistoryStart, - distanceFromHistoryStart: 300, - progressKey: "epoch-1:10", - }); - const returned = evaluateHistoryStartPagination(settledAway.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - - expect([ - first.shouldLoad, - pageApplied.shouldLoad, - settledAway.shouldLoad, - returned.shouldLoad, - ]).toEqual([true, false, false, true]); + it("releases a failed or stalled request and allows retry", async () => { + vi.useFakeTimers(); + const h = harness(); + h.pagination.update(); + vi.advanceTimersByTime(15_000); + expect(h.pagination.state).toEqual({ status: "failed", visible: true }); + h.pagination.rearm(); + expect(h.requests).toHaveLength(2); + h.requests[1](false); + await Promise.resolve(); + expect(h.pagination.state.status).toBe("failed"); + h.pagination.dispose(); + vi.useRealTimers(); }); - - it("continues the same loading operation when anchored geometry remains at history start", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const pageApplied = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - const continued = settleHistoryStartPagination(pageApplied.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - - expect(continued.shouldLoad).toBe(true); - expect(isHistoryStartLoadingOperation(first.state)).toBe(true); - expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true); - expect(isHistoryStartLoadingOperation(continued.state)).toBe(true); + it("keeps visible history visible while filling", () => { + const h = harness(true); + h.pagination.update(); + expect(h.pagination.state.visible).toBe(true); + h.pagination.dispose(); }); - - it("latches a request that finishes without cursor progress", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); - const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart); - const away = evaluateHistoryStartPagination(duplicate.state, { - ...visibleHistoryStart, - distanceFromHistoryStart: 300, - }); - - expect(finished.state).toEqual({ status: "latched" }); - expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([ - false, - false, - false, - ]); - expect(away.state).toEqual({ status: "ready" }); + it("does not load a full viewport until user intent and respects inactive views", () => { + const h = harness(); + h.set({ isUnderfilled: false }); + expect(h.requests).toHaveLength(0); + h.set({ isReady: false }); + h.pagination.rearm(); + expect(h.requests).toHaveLength(0); + h.set({ isReady: true }); + expect(h.requests).toHaveLength(1); + h.pagination.dispose(); }); - - it("allows another user attempt after a request finishes without progress", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); - const retried = evaluateHistoryStartPagination( - rearmHistoryStartPagination(failed.state), - visibleHistoryStart, - ); - - expect(failed.state).toEqual({ status: "latched" }); - expect(retried.shouldLoad).toBe(true); - }); - - it("latches an attempt that becomes invalid before entering flight", () => { - const requested = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - - expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({ - status: "latched", - }); - }); - - it("does not mistake repeated edge observations for a finished request", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart); - - expect(repeated.state).toBe(first.state); - expect(repeated.shouldLoad).toBe(false); - }); - - it("waits while history loading is unavailable or already active", () => { - const state = createArmedHistoryStartPaginationState(); - - expect([ - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad, - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false }) - .shouldLoad, - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true }) - .shouldLoad, - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null }) - .shouldLoad, - ]).toEqual([false, false, false, false]); + it("preserves the reading position after an anchored page moves away from the start", async () => { + const h = harness(true); + h.set({ isUnderfilled: false }); + h.pagination.rearm(); + h.set({ progressKey: "10", distanceFromHistoryStart: 300 }); + h.requests[0](true); + await Promise.resolve(); + h.pagination.settle(); + expect(h.requests).toHaveLength(1); + h.set({ distanceFromHistoryStart: 0 }); + expect(h.requests).toHaveLength(2); + h.pagination.dispose(); }); }); + +it("ignores a timed-out response after the reader retries", async () => { + vi.useFakeTimers(); + const h = harness(); + h.pagination.update(); + vi.advanceTimersByTime(15_000); + h.pagination.rearm(); + h.requests[0](true); + await Promise.resolve(); + expect(h.pagination.state.status).toBe("loading"); + expect(h.settles).toBe(0); + h.requests[1](false); + await Promise.resolve(); + expect(h.pagination.state.status).toBe("failed"); + h.pagination.dispose(); + vi.useRealTimers(); +}); + +it("does not repeat a successful page that made no progress", async () => { + vi.useFakeTimers(); + const h = harness(); + h.pagination.update(); + h.requests[0](true); + await Promise.resolve(); + h.pagination.update(); + expect(h.requests).toHaveLength(1); + vi.advanceTimersByTime(15_000); + expect(h.pagination.state).toEqual({ status: "failed", visible: true }); + h.pagination.dispose(); + vi.useRealTimers(); +}); + +it("waits until the cursor and active history are available", () => { + const h = harness(); + h.set({ isReady: false, progressKey: null }); + h.set({ isReady: true, isLoadingOlderHistory: true }); + h.set({ isLoadingOlderHistory: false }); + expect(h.requests).toHaveLength(0); + h.set({ progressKey: "20" }); + expect(h.requests).toHaveLength(1); + h.pagination.dispose(); +}); + +it("reveals immediately on exhausted history without offering retry", () => { + const h = harness(); + h.set({ hasOlderHistory: false }); + expect(h.requests).toHaveLength(0); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); +}); + +it("keeps a settled page pending while its view is inactive", async () => { + const h = harness(); + h.pagination.update(); + h.set({ progressKey: "10" }); + h.requests[0](true); + await Promise.resolve(); + h.set({ isReady: false }); + h.pagination.settle(); + expect(h.pagination.state.status).toBe("settling"); + expect(h.requests).toHaveLength(1); + h.set({ isReady: true, isUnderfilled: false }); + h.pagination.settle(); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); +}); + +it("keeps cached history visible after synchronization and subsequent pagination", async () => { + const h = harness(); + h.pagination.reveal(); + h.pagination.update(); + h.set({ progressKey: "10" }); + h.requests[0](true); + await Promise.resolve(); + h.pagination.settle(); + expect(h.pagination.state.visible).toBe(true); + expect(h.requests).toHaveLength(2); + h.pagination.dispose(); +}); + +it.each([true, false])( + "handles native padding when short history has older=%s", + (hasOlderHistory) => { + const geometry = nativeHistoryStartGeometry({ + contentHeight: 825, + viewportHeight: 700, + offsetY: 125, + topOverlayInset: 123, + }); + let requests = 0; + const pagination = createHistoryStartPagination({ + initiallyVisible: false, + read: () => ({ ...initial, ...geometry, hasOlderHistory }), + load: () => { + requests++; + return new Promise(() => {}); + }, + changed: () => {}, + scheduleSettle: () => {}, + }); + pagination.update(); + expect(requests).toBe(hasOlderHistory ? 1 : 0); + expect(pagination.state.visible).toBe(!hasOlderHistory); + pagination.dispose(); + }, +); + +it("uses the forward native list's top edge to request older history", () => { + let offsetY = 1300; + let requests = 0; + const pagination = createHistoryStartPagination({ + initiallyVisible: true, + read: () => ({ + ...initial, + ...nativeHistoryStartGeometry({ + contentHeight: 2000, + viewportHeight: 700, + offsetY, + topOverlayInset: 123, + }), + }), + load: () => { + requests++; + return new Promise(() => {}); + }, + changed: () => {}, + scheduleSettle: () => {}, + }); + pagination.rearm(); + expect(requests).toBe(0); + offsetY = 155; + pagination.update(); + expect(requests).toBe(1); + pagination.dispose(); +}); + +it.each([null, { ...initial, isReady: false }])( + "resumes a completed page after its view stays inactive beyond the deadline (%j)", + async (inactive) => { + vi.useFakeTimers(); + let geometry: HistoryStartPaginationInput | null = { ...initial }; + let complete!: (applied: boolean) => void; + let requests = 0; + const pagination = createHistoryStartPagination({ + initiallyVisible: false, + read: () => geometry, + load: () => { + requests++; + return new Promise((resolve) => { + complete = resolve; + }); + }, + changed: () => {}, + scheduleSettle: () => {}, + }); + try { + pagination.update(); + geometry = inactive; + pagination.update(); + complete(true); + await Promise.resolve(); + vi.advanceTimersByTime(30_000); + expect(pagination.state.status).toBe("loading"); + geometry = { ...initial, progressKey: "10" }; + pagination.update(); + expect(pagination.state.status).toBe("settling"); + geometry = inactive; + pagination.update(); + pagination.settle(); + vi.advanceTimersByTime(30_000); + expect(pagination.state.status).toBe("settling"); + geometry = { ...initial, progressKey: "10" }; + pagination.update(); + pagination.settle(); + expect(requests).toBe(2); + expect(pagination.state.status).toBe("loading"); + vi.advanceTimersByTime(15_000); + expect(pagination.state.status).toBe("failed"); + } finally { + pagination.dispose(); + vi.useRealTimers(); + } + }, +); diff --git a/packages/app/src/agent-stream/history-start-pagination.ts b/packages/app/src/agent-stream/history-start-pagination.ts index 46914ae3e1..9931d4d469 100644 --- a/packages/app/src/agent-stream/history-start-pagination.ts +++ b/packages/app/src/agent-stream/history-start-pagination.ts @@ -1,138 +1,149 @@ export const HISTORY_START_THRESHOLD_PX = 96; -export type HistoryStartPaginationState = - | { status: "dormant" } - | { status: "ready" } - | { status: "loading"; requestedProgressKey: string; requestObserved: boolean } - | { status: "settling"; loadedProgressKey: string } - | { status: "latched" }; - export interface HistoryStartPaginationInput { distanceFromHistoryStart: number; + isUnderfilled: boolean; hasOlderHistory: boolean; isLoadingOlderHistory: boolean; isReady: boolean; progressKey: string | null; } -export interface HistoryStartPaginationTransition { - state: HistoryStartPaginationState; - shouldLoad: boolean; -} - -export function createHistoryStartPaginationState(): HistoryStartPaginationState { - return { status: "dormant" }; +export interface HistoryStartPaginationState { + status: "dormant" | "ready" | "loading" | "settling" | "failed"; + visible: boolean; } export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean { return state.status === "loading" || state.status === "settling"; } -export function rearmHistoryStartPagination( - state: HistoryStartPaginationState, -): HistoryStartPaginationState { - return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state; -} +/** Owns one pagination operation, including the initial viewport reveal. */ +export function createHistoryStartPagination(input: { + initiallyVisible: boolean; + read: () => HistoryStartPaginationInput | null; + load: () => Promise; + changed: (state: HistoryStartPaginationState) => void; + scheduleSettle: () => void; +}) { + let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible }; + let requestedKey: string | null = null; + let completed = false; + let fill = false; + let generation = 0; + let timeout: ReturnType | undefined; -export function abandonHistoryStartPaginationRequest( - state: HistoryStartPaginationState, - requestedProgressKey: string, -): HistoryStartPaginationState { - if ( - state.status !== "loading" || - state.requestObserved || - state.requestedProgressKey !== requestedProgressKey - ) { - return state; - } - return { status: "latched" }; -} + const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => { + if (status === state.status && visible === state.visible) return; + state = { status, visible }; + input.changed(state); + }; + const clear = () => { + generation++; + clearTimeout(timeout); + timeout = undefined; + }; + const finish = (status: HistoryStartPaginationState["status"]) => { + clear(); + publish(status, true); + }; + const fail = () => finish("failed"); -export function evaluateHistoryStartPagination( - state: HistoryStartPaginationState, - input: HistoryStartPaginationInput, -): HistoryStartPaginationTransition { - if (state.status === "dormant") { - return { state, shouldLoad: false }; - } - if (state.status === "loading") { - if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) { - return { - state: { status: "settling", loadedProgressKey: input.progressKey }, - shouldLoad: false, - }; + const watchProgress = (geometry: HistoryStartPaginationInput | null) => { + if (completed && !geometry?.isReady) { + clearTimeout(timeout); + timeout = undefined; + } else if (timeout === undefined) { + const request = generation; + timeout = setTimeout(() => { + timeout = undefined; + // A completed request waits for its retained view to become measurable again. + // Unfinished requests still time out even while the view is inactive. + if (request === generation && (!completed || input.read()?.isReady)) fail(); + }, 15_000); } - if (input.isLoadingOlderHistory && !state.requestObserved) { - return { state: { ...state, requestObserved: true }, shouldLoad: false }; - } - if (!input.isLoadingOlderHistory && !input.hasOlderHistory) { - return { state: { status: "latched" }, shouldLoad: false }; - } - if ( - state.requestObserved && - !input.isLoadingOlderHistory && - input.progressKey === state.requestedProgressKey - ) { - return { state: { status: "latched" }, shouldLoad: false }; - } - return { state, shouldLoad: false }; - } + }; - if (state.status === "settling") { - return { state, shouldLoad: false }; - } - - const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; - if (!isAtHistoryStart) { - return state.status === "ready" - ? { state, shouldLoad: false } - : { state: { status: "ready" }, shouldLoad: false }; - } - if ( - state.status === "latched" || - !input.isReady || - !input.hasOlderHistory || - input.isLoadingOlderHistory || - input.progressKey === null - ) { - return { state, shouldLoad: false }; - } + const update = () => { + const geometry = input.read(); + if (isHistoryStartLoadingOperation(state)) watchProgress(geometry); + if (!geometry) return; + if (state.status === "loading") { + if (!completed) return; + if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) { + publish("settling"); + input.scheduleSettle(); + } else if (!geometry.hasOlderHistory) { + finish("dormant"); + } + return; + } + if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return; + const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; + if (state.status === "dormant" && !geometry.isUnderfilled) { + publish("dormant", true); + return; + } + if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return; + if (!geometry.hasOlderHistory) { + finish("dormant"); + return; + } + if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return; + fill = geometry.isUnderfilled; + requestedKey = geometry.progressKey; + completed = false; + publish("loading"); + const request = ++generation; + watchProgress(geometry); + void input.load().then( + (applied) => { + if (request !== generation) return undefined; + if (!applied) { + fail(); + return undefined; + } + completed = true; + update(); + return undefined; + }, + () => { + if (request === generation) fail(); + }, + ); + }; return { - state: { - status: "loading", - requestedProgressKey: input.progressKey, - requestObserved: false, + get state() { + return state; }, - shouldLoad: true, - }; -} - -export function settleHistoryStartPagination( - state: HistoryStartPaginationState, - input: HistoryStartPaginationInput, -): HistoryStartPaginationTransition { - if (state.status !== "settling") { - return { state, shouldLoad: false }; - } - const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; - if ( - !isAtHistoryStart || - !input.isReady || - !input.hasOlderHistory || - input.isLoadingOlderHistory || - input.progressKey === null - ) { - return { - state: isAtHistoryStart ? { status: "latched" } : { status: "ready" }, - shouldLoad: false, - }; - } - return { - state: { - status: "loading", - requestedProgressKey: input.progressKey, - requestObserved: false, + update, + settle() { + if (state.status !== "settling") return; + const geometry = input.read(); + if (!geometry?.isReady) return; + clear(); + if (fill && !geometry.isUnderfilled) { + publish("dormant", true); + } else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) { + publish("ready", true); + } else if (!geometry.hasOlderHistory) { + publish("dormant", true); + } else { + publish("ready"); + update(); + } }, - shouldLoad: true, + rearm() { + if (state.status === "dormant" || state.status === "failed") publish("ready", true); + update(); + }, + reveal() { + publish(state.status, true); + }, + reset(visible: boolean) { + clear(); + publish("dormant", visible); + }, + dispose: clear, }; } diff --git a/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx index a16763f467..338fa84008 100644 --- a/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx +++ b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx @@ -3,6 +3,7 @@ import React, { act, useSyncExternalStore } from "react"; import { createRoot, type Root } from "react-dom/client"; import { View } from "react-native"; import { afterEach, beforeEach, expect, it } from "vitest"; +import { disclosureAnchor } from "./native-disclosure-hold"; // The native chat timeline runs on the patched LegendList (patches/@legendapp+list+3.7.1.patch). // A disclosure hold keeps only the held row in place: `shouldRestorePosition` accepts no other row. @@ -130,7 +131,16 @@ function mountList(heldRowId: string) { return position - state.scroll; }; const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId); - return { render, scrollTo, heldRowTop, rowSize }; + const rowBottom = (rowId: string) => { + const state = listRef.current?.getState(); + const position = state?.positionByKey(rowId); + const size = state?.sizes.get(rowId); + if (state === undefined || position === undefined || size === undefined) { + throw new Error(`Row ${rowId} is not laid out`); + } + return position + size - state.scroll; + }; + return { render, scrollTo, heldRowTop, rowSize, rowBottom }; } it("anchors a data change on a held row whose top is above the viewport", async () => { @@ -171,6 +181,72 @@ it("anchors a size change on a held row whose top is above the viewport", async expect(list.heldRowTop()).toBe(-20); }); +// A long message's show more sits under the text it opens, on the bottom edge of its row. The +// strategy anchors the row below it, whose top is that bottom edge. +const bottomTriggerRows: Row[] = Array.from({ length: 10 }, (_, index) => ({ + id: `row-${index}`, + height: index === 3 ? 60 : 40, +})); + +function mountBottomTriggerHold(rowId: string) { + const anchor = disclosureAnchor(bottomTriggerRows, rowId, "bottom"); + if (anchor.kind !== "row") throw new Error(`Row ${rowId} has no row below it`); + return mountList(anchor.rowId); +} + +it("keeps the bottom of a row whose trigger sits under its growing content", async () => { + const list = mountBottomTriggerHold("row-3"); + list.render(bottomTriggerRows); + await wait(150); + await list.scrollTo(100); + const triggerBottom = list.rowBottom("row-3"); + expect(triggerBottom).toBe(80); + + act(() => growRow("row-3", 260)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(triggerBottom); + + act(() => growRow("row-3", 60)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(triggerBottom); +}); + +it("keeps the bottom of a bottom-trigger row whose top is above the viewport", async () => { + const list = mountBottomTriggerHold("row-3"); + list.render(bottomTriggerRows); + await wait(150); + await list.scrollTo(140); + const triggerBottom = list.rowBottom("row-3"); + expect(triggerBottom).toBe(40); + + act(() => growRow("row-3", 260)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(triggerBottom); + + act(() => growRow("row-3", 60)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(triggerBottom); +}); + +it("keeps the bottom of a bottom-trigger row that ends below the viewport", async () => { + // In the app the row ends with the message's timestamp under the trigger, so a trigger near the + // bottom of the viewport has its row's bottom, and the row below, out of view. + const list = mountBottomTriggerHold("row-3"); + list.render(bottomTriggerRows); + await wait(150); + await list.scrollTo(40); + const rowBottom = list.rowBottom("row-3"); + expect(rowBottom).toBe(140); + + act(() => growRow("row-3", 260)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(rowBottom); + + act(() => growRow("row-3", 60)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(rowBottom); +}); + it("keeps measured row sizes while the list is not laid out", async () => { // A hidden retained tab is display: none. Its rows still render, and a row measured there // reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout diff --git a/packages/app/src/agent-stream/native-disclosure-hold.test.ts b/packages/app/src/agent-stream/native-disclosure-hold.test.ts index b732edbb01..507ca07700 100644 --- a/packages/app/src/agent-stream/native-disclosure-hold.test.ts +++ b/packages/app/src/agent-stream/native-disclosure-hold.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { createNativeDisclosureHold } from "./native-disclosure-hold"; +import { + createNativeDisclosureHold, + disclosureAnchor, + createEndHoldDistance, + type DisclosureAnchor, +} from "./native-disclosure-hold"; function createFrames() { let nextId = 1; @@ -39,22 +44,22 @@ describe("native disclosure hold", () => { it("holds the row until two frames pass without an item size change", () => { const { frames, hold, releases } = createHarness(); - hold.hold("turn-1"); + hold.hold({ kind: "row", rowId: "turn-1" }); frames.runFrame(); - expect(hold.heldRowId()).toBe("turn-1"); + expect(hold.anchorRowId()).toBe("turn-1"); expect(releases).toEqual([]); frames.runFrame(); - expect(hold.heldRowId()).toBeNull(); + expect(hold.anchorRowId()).toBeNull(); expect(releases).toEqual(["released"]); }); it("restarts the quiet frames on every item size change", () => { const { frames, hold, releases } = createHarness(); - hold.hold("turn-1"); + hold.hold({ kind: "row", rowId: "turn-1" }); frames.runFrame(); hold.noteItemSizeChanged(); frames.runFrame(); @@ -79,11 +84,11 @@ describe("native disclosure hold", () => { it("moves the hold to the latest toggled row and releases once", () => { const { frames, hold, releases } = createHarness(); - hold.hold("turn-1"); + hold.hold({ kind: "row", rowId: "turn-1" }); frames.runFrame(); - hold.hold("turn-2"); + hold.hold({ kind: "row", rowId: "turn-2" }); - expect(hold.heldRowId()).toBe("turn-2"); + expect(hold.anchorRowId()).toBe("turn-2"); frames.runFrame(); frames.runFrame(); @@ -94,12 +99,137 @@ describe("native disclosure hold", () => { it("drops the hold without releasing when cancelled", () => { const { frames, hold, releases } = createHarness(); - hold.hold("turn-1"); + hold.hold({ kind: "row", rowId: "turn-1" }); hold.cancel(); frames.runFrame(); frames.runFrame(); - expect(hold.heldRowId()).toBeNull(); + expect(hold.anchorRowId()).toBeNull(); expect(releases).toEqual([]); }); + + it("holds the content end until two quiet frames pass, with no row to anchor on", () => { + const { frames, hold, releases } = createHarness(); + + hold.hold({ kind: "end" }); + frames.runFrame(); + hold.noteItemSizeChanged(); + frames.runFrame(); + + expect(hold.anchorRowId()).toBeNull(); + expect(releases).toEqual([]); + + frames.runFrame(); + + expect(releases).toEqual(["released"]); + }); +}); + +describe("next-frame disclosure hold", () => { + it("anchors the row a reply landed as while the toggle waited for its frame", () => { + const { frames, hold } = createHarness(); + let anchor: DisclosureAnchor = { kind: "end" }; + const applied: DisclosureAnchor[] = []; + + hold.holdNextFrame( + () => anchor, + (resolved) => applied.push(resolved), + ); + expect(applied).toEqual([]); + + anchor = { kind: "row", rowId: "turn-2" }; + frames.runFrame(); + + expect(hold.anchorRowId()).toBe("turn-2"); + expect(applied).toEqual([{ kind: "row", rowId: "turn-2" }]); + }); + + it("still lands the toggle when the hold is cancelled before its frame", () => { + const { frames, hold, releases } = createHarness(); + const applied: DisclosureAnchor[] = []; + + hold.holdNextFrame( + () => ({ kind: "end" }), + (resolved) => applied.push(resolved), + ); + hold.cancel(); + frames.runFrame(); + frames.runFrame(); + + expect(applied).toEqual([{ kind: "end" }]); + expect(releases).toEqual([]); + }); + + it("lands a waiting toggle before the next hold starts", () => { + const { frames, hold } = createHarness(); + const applied: DisclosureAnchor[] = []; + + hold.holdNextFrame( + () => ({ kind: "end" }), + (resolved) => applied.push(resolved), + ); + hold.hold({ kind: "row", rowId: "turn-1" }); + + expect(applied).toEqual([{ kind: "end" }]); + frames.runFrame(); + expect(applied).toEqual([{ kind: "end" }]); + expect(hold.anchorRowId()).toBe("turn-1"); + }); +}); + +describe("disclosure anchor", () => { + const rows = [{ id: "message-1" }, { id: "turn-1" }, { id: "message-2" }]; + + it("keeps the top of a row whose trigger sits on its top edge", () => { + expect(disclosureAnchor(rows, "turn-1", "top")).toEqual({ kind: "row", rowId: "turn-1" }); + }); + + it("keeps the top of the row below a row whose trigger sits on its bottom edge", () => { + expect(disclosureAnchor(rows, "message-1", "bottom")).toEqual({ + kind: "row", + rowId: "turn-1", + }); + }); + + // A prompt the agent has not answered yet is the last row; the running indicator is the footer. + it("keeps the content end for a bottom-edge trigger on the last row", () => { + expect(disclosureAnchor(rows, "message-2", "bottom")).toEqual({ kind: "end" }); + }); + + // The strategy re-derives this each render, so a reply lets go of the end in the commit it lands. + it("stops keeping the content end once a reply lands below the row", () => { + expect(disclosureAnchor([...rows, { id: "turn-2" }], "message-2", "bottom")).toEqual({ + kind: "row", + rowId: "turn-2", + }); + }); +}); + +describe("end hold distance", () => { + const viewportHeight = 600; + + it("keeps the distance from the end the toggle started at", () => { + const distance = createEndHoldDistance(20); + + distance.noteContentSize({ contentHeight: 2000, viewportHeight }); + + expect(distance.current()).toBe(20); + }); + + it("shrinks to the scroll room a collapse leaves", () => { + const distance = createEndHoldDistance(20); + + distance.noteContentSize({ contentHeight: 610, viewportHeight }); + + expect(distance.current()).toBe(10); + }); + + it("stays at the end when output grows the content after a collapse reached the end", () => { + const distance = createEndHoldDistance(20); + + distance.noteContentSize({ contentHeight: 600, viewportHeight }); + distance.noteContentSize({ contentHeight: 700, viewportHeight }); + + expect(distance.current()).toBe(0); + }); }); diff --git a/packages/app/src/agent-stream/native-disclosure-hold.ts b/packages/app/src/agent-stream/native-disclosure-hold.ts index 96b5087b98..7dc7a55bd0 100644 --- a/packages/app/src/agent-stream/native-disclosure-hold.ts +++ b/packages/app/src/agent-stream/native-disclosure-hold.ts @@ -1,13 +1,30 @@ +import type { DisclosureTriggerEdge } from "./strategy"; + +/** What a disclosure hold keeps in place: the top of a row, or the end of the content. */ +export type DisclosureAnchor = { kind: "row"; rowId: string } | { kind: "end" }; + /** - * The native viewport's disclosure hold. While a row is held, it is the only row the list may - * anchor on. The hold ends once item sizes stop changing for `quietFrames` frames: the list - * measures the toggled row and any rows it inserted over one or more frames, and end following - * must not resume between the change and the list's last layout correction. + * The native viewport's disclosure hold. While it holds a row anchor, that row is the only row the + * list may anchor on (`disclosureAnchor`). The hold ends once item sizes stop changing for + * `quietFrames` frames: the list measures the toggled row and any rows it inserted over one or more + * frames, and end following must not resume between the change and the list's last layout + * correction. */ export interface NativeDisclosureHold { - heldRowId(): string | null; - hold(rowId: string): void; + /** The row the list may anchor on; null outside a hold and while the hold keeps the end. */ + anchorRowId(): string | null; + hold(anchor: DisclosureAnchor): void; + /** + * Holds on the next frame and runs `apply` there, for a toggle that has to land after a commit + * already on its way. `resolveAnchor` runs in that frame, because rows may land while it waits. + * A later hold or a cancel lands a waiting toggle right away. + */ + holdNextFrame( + resolveAnchor: () => DisclosureAnchor, + apply: (anchor: DisclosureAnchor) => void, + ): void; noteItemSizeChanged(): void; + /** Drops the hold without releasing it. */ cancel(): void; } @@ -17,9 +34,10 @@ export function createNativeDisclosureHold(input: { cancelFrame: (id: number) => void; onRelease: () => void; }): NativeDisclosureHold { - let heldRowId: string | null = null; + let anchor: DisclosureAnchor | null = null; let frameId: number | null = null; let remainingFrames = 0; + let waitingToggle: { frameId: number; land: () => void } | null = null; const stopCountdown = () => { if (frameId !== null) { @@ -35,7 +53,7 @@ export function createNativeDisclosureHold(input: { return; } frameId = null; - heldRowId = null; + anchor = null; input.onRelease(); }; @@ -45,20 +63,80 @@ export function createNativeDisclosureHold(input: { frameId = input.requestFrame(tick); }; + const landWaitingToggle = () => { + if (!waitingToggle) return; + const { frameId: waitingFrameId, land } = waitingToggle; + waitingToggle = null; + input.cancelFrame(waitingFrameId); + land(); + }; + + const startHold = (nextAnchor: DisclosureAnchor) => { + anchor = nextAnchor; + restartCountdown(); + }; + return { - heldRowId: () => heldRowId, - hold(rowId) { - heldRowId = rowId; - restartCountdown(); + anchorRowId: () => (anchor?.kind === "row" ? anchor.rowId : null), + hold(nextAnchor) { + landWaitingToggle(); + startHold(nextAnchor); + }, + holdNextFrame(resolveAnchor, apply) { + landWaitingToggle(); + const waitingFrameId = input.requestFrame(() => { + waitingToggle = null; + const nextAnchor = resolveAnchor(); + startHold(nextAnchor); + apply(nextAnchor); + }); + waitingToggle = { frameId: waitingFrameId, land: () => apply(resolveAnchor()) }; }, noteItemSizeChanged() { - if (heldRowId !== null) { + if (anchor !== null) { restartCountdown(); } }, cancel() { + landWaitingToggle(); stopCountdown(); - heldRowId = null; + anchor = null; }, }; } + +/** + * What the list keeps in place while a disclosure toggles `rowId`. LegendList restores row tops, so + * a trigger on the bottom edge of its row anchors the row below: that row's top is the toggled + * row's bottom. The last row's bottom is the end of the content. + */ +export function disclosureAnchor( + rows: readonly { id: string }[], + rowId: string, + triggerEdge: DisclosureTriggerEdge, +): DisclosureAnchor { + if (triggerEdge === "top") return { kind: "row", rowId }; + const index = rows.findIndex((row) => row.id === rowId); + const below = rows[index + 1]; + return below ? { kind: "row", rowId: below.id } : { kind: "end" }; +} + +/** + * The list's distance from the end during an end hold. The end pin keeps the distance through every + * content size change, and the scroll view clamps it to the scroll room the content has at that + * moment. A clamp is permanent: content that grows afterwards does not bring the distance back. + */ +export function createEndHoldDistance(distanceAtToggle: number): EndHoldDistance { + let distance = distanceAtToggle; + return { + noteContentSize({ contentHeight, viewportHeight }) { + distance = Math.min(distance, Math.max(0, contentHeight - viewportHeight)); + }, + current: () => distance, + }; +} + +export interface EndHoldDistance { + noteContentSize(input: { contentHeight: number; viewportHeight: number }): void; + current(): number; +} diff --git a/packages/app/src/agent-stream/native-history-start-geometry.ts b/packages/app/src/agent-stream/native-history-start-geometry.ts new file mode 100644 index 0000000000..6df65f48a1 --- /dev/null +++ b/packages/app/src/agent-stream/native-history-start-geometry.ts @@ -0,0 +1,12 @@ +export function nativeHistoryStartGeometry(input: { + contentHeight: number; + viewportHeight: number; + offsetY: number; + topOverlayInset: number; +}) { + const contentHeight = input.contentHeight - 32 - input.topOverlayInset; + return { + distanceFromHistoryStart: Math.max(0, input.offsetY - input.topOverlayInset - 32), + isUnderfilled: contentHeight < input.viewportHeight, + }; +} diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 9f8213d716..48a8ab757b 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -608,8 +608,11 @@ it("folds work before a plugin-rendered reply and leaves the reply visible", () display: "collapseTurns", isTurnActive: false, }); - expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]); - expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]); + expect(result.tail.map((item) => item.id)).toEqual([ + "prompt", + "turn-summary:prompt", + "probe-plugin/reply/0", + ]); expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]); }); @@ -636,12 +639,14 @@ it("keeps streaming replies native, then folds work without losing manual expans const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false }; expect(present(completed).tail.map((item) => item.id)).toEqual([ "prompt", + "turn-summary:prompt", "probe-plugin/reply/0", ]); const expandedTurnHostIds = new Set(["prompt"]); const opened = present({ ...completed, expandedTurnHostIds }); expect(opened.tail.map((item) => item.id)).toEqual([ "prompt", + "turn-summary:prompt", "tool-one", "probe-plugin/reply/0", ]); @@ -649,6 +654,7 @@ it("keeps streaming replies native, then folds work without losing manual expans expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail); expect(present(completed).tail.map((item) => item.id)).toEqual([ "prompt", + "turn-summary:prompt", "probe-plugin/reply/0", ]); }); @@ -675,7 +681,7 @@ it.each(["full", "collapseToolCalls"] as const)( "tool-one", "probe-plugin/reply/0", ]); - expect(result.turns.turnsByHostId.size).toBe(0); + expect(result.turns.summaryRowsByHostId.size).toBe(0); }, ); @@ -697,7 +703,13 @@ it("opening a turn reveals individual calls rather than another tool group", () isTurnActive: false, expandedTurnHostIds: new Set(["prompt"]), }); - expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id)); + expect(result.tail.map((item) => item.id)).toEqual([ + "prompt", + "turn-summary:prompt", + "tool-one", + "tool-two", + "reply", + ]); expect(result.groupsByHostId.size).toBe(0); expect(result.readingRowReplacements.get("tool-two")).toBeUndefined(); }); @@ -739,7 +751,7 @@ it.each(["collapseToolCalls", "collapseTurns"] as const)( { kind: "assistant_message", id: "reply", text: "Done", timestamp }, ], }); - // Both the original partial group and its replacement resolve directly to the turn. + // Both the original partial group and its replacement resolve directly to the turn's host. expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt"); expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt"); const opened = present({ ...input, tail: calls, display: "full" }); @@ -788,7 +800,7 @@ it("does not reopen the previous turn while a new run waits for its first row", isTurnActive: true, activeTurnId: "new", }); - expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]); }); // A sent prompt counts as activity before the daemon opens its turn, so the only known turn is @@ -810,7 +822,7 @@ it("keeps the previous turn folded while a sent prompt waits for its turn to ope isTurnActive: true, activeTurnId: null, }); - expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]); }); it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => { @@ -867,7 +879,13 @@ it.each([false, true])( const closed = present(input); expect(closed.tail.some((item) => item.id === "thought")).toBe(false); const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) }); - expect(opened.tail).toEqual(tail); + expect(opened.tail.map((item) => item.id)).toEqual([ + "prompt", + "turn-summary:prompt", + "thought", + "tool-one", + "reply", + ]); expect(opened.turns.nestedItemIds.has("thought")).toBe(true); expect(opened.groupsByHostId.size).toBe(0); }, diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 9a5ff6469e..8e197aed23 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -50,7 +50,7 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({ isLatestTurnOpen: false, expandedHostIds: new Set(), getGroupedCalls: () => undefined, - previousTurnsByHostId: new Map(), + previousSummaryRowsByHostId: new Map(), }); /** Source messages reach plugins before display projection. */ @@ -135,7 +135,8 @@ export function createStreamPresentation() { isLatestTurnOpen, expandedHostIds: input.expandedTurnHostIds, getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls, - previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId, + previousSummaryRowsByHostId: + collapsed?.summaryRowsByHostId ?? NO_COLLAPSED_TURNS.summaryRowsByHostId, }); collapsedSource = projection.tail; collapsedIsLatestTurnOpen = isLatestTurnOpen; diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts index 30d30ab1a0..a726203fc2 100644 --- a/packages/app/src/agent-stream/reading-anchor.test.ts +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -7,7 +7,7 @@ describe("reading anchor", () => { anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]); const replacements = new Map([["tool", "prompt"]]); expect(anchor.getRowId(replacements)).toBe("prompt"); - expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000); + expect(anchor.project(0, { id: "prompt", top: 1048, height: 100 }, replacements)).toBe(1000); // Projection does not consume the correction; an unmounted host keeps the pin. expect(anchor.reconcile(0, [], false, replacements)).toBe(0); expect(anchor.getRowId()).toBe("tool"); @@ -59,13 +59,32 @@ describe("reading anchor", () => { it("keeps a pinned row in place instead of the row under the reading line", () => { const anchor = createReadingAnchor(); anchor.reconcile(600, rows); - anchor.pin(rows[2]!); + anchor.pin(rows[2]!, "top", 600); // The row being read grows; only the pinned row below it holds its position. expect( anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 800 }, { ...rows[2]!, top: 1300 }]), ).toBe(900); }); + it("keeps the bottom of a row pinned by a trigger below its content", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.pin(rows[2]!, "bottom", 600); + // The pinned row grows downward; its bottom, where the trigger sits, holds its position. + const grown = { ...rows[2]!, height: rows[2]!.height + 300 }; + expect(anchor.reconcile(600, [rows[0]!, rows[1]!, grown])).toBe(900); + }); + + it("measures a pinned row's correction from the offset at the toggle", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(900, rows); + anchor.pin(rows[2]!, "bottom", 900); + // The row shrinks and the browser clamps the offset to the shorter content before the + // reconcile reads it. + const shrunk = { ...rows[2]!, height: rows[2]!.height - 300 }; + expect(anchor.reconcile(700, [rows[0]!, rows[1]!, shrunk])).toBe(600); + }); + it("leaves expansion of the row being read in place", () => { const anchor = createReadingAnchor(); anchor.reconcile(600, rows); @@ -109,8 +128,8 @@ describe("reading anchor", () => { it("projects a prepend range without consuming the correction before layout commits", () => { const anchor = createReadingAnchor(); anchor.reconcile(600, rows); - expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440); - expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440); + expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440); + expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440); expect( anchor.reconcile( 440, diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts index ed2baa552a..88ea9e7a9d 100644 --- a/packages/app/src/agent-stream/reading-anchor.ts +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -1,9 +1,14 @@ +import type { DisclosureTriggerEdge } from "./strategy"; + interface RowGeometry { id: string; top: number; height: number; } +const edgeOf = (row: RowGeometry, edge: DisclosureTriggerEdge) => + edge === "top" ? row.top : row.top + row.height; + // A row must clear the reading line before the next row takes ownership. const READING_POSITION_OFFSET_PX = 8; export interface ReadingRowReplacements { @@ -14,7 +19,16 @@ const NO_REPLACEMENTS: ReadingRowReplacements = new Map(); // Content coordinates survive user scrolling. Layout commits replace the geometry; // scroll events reuse it, without another DOM measurement pass. export function createReadingAnchor() { - let anchor: { id: string; top: number } | null = null; + // The row being read keeps its top. A disclosure pins the edge of its row the trigger sits on, + // with the offset at the toggle: a toggle that shrinks the content lets the browser clamp the + // offset before the next reconcile reads it. + let anchor: { + id: string; + edge: DisclosureTriggerEdge; + position: number; + pinnedScrollTop?: number; + } | null = null; + const anchorTop = (row: RowGeometry) => ({ id: row.id, edge: "top" as const, position: row.top }); let geometry: readonly RowGeometry[] = []; const readingRow = (scrollTop: number) => geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX); @@ -22,9 +36,12 @@ export function createReadingAnchor() { anchor ? (replacements.get(anchor.id) ?? anchor.id) : null; const project = ( scrollTop: number, - row: Pick | undefined, + row: RowGeometry | undefined, replacements = NO_REPLACEMENTS, - ) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0); + ) => + anchor && row && row.id === getRowId(replacements) + ? (anchor.pinnedScrollTop ?? scrollTop) + edgeOf(row, anchor.edge) - anchor.position + : scrollTop; return { getRowId, getReadingRowId: (scrollTop: number) => @@ -33,14 +50,14 @@ export function createReadingAnchor() { reset() { anchor = null; }, - // A disclosure names the row that owns its trigger. That row keeps its top - // through the toggle, even when another row sits under the reading line. - pin(row: Pick) { - anchor = { id: row.id, top: row.top }; + // A disclosure names the row that owns its trigger and the edge the trigger sits on. That + // edge stays in place through the toggle, even when another row sits under the reading line. + pin(row: RowGeometry, edge: DisclosureTriggerEdge, scrollTop: number) { + anchor = { id: row.id, edge, position: edgeOf(row, edge), pinnedScrollTop: scrollTop }; }, scroll(scrollTop: number) { const next = readingRow(scrollTop); - anchor = next ? { id: next.id, top: next.top } : null; + anchor = next ? anchorTop(next) : null; }, reconcile( scrollTop: number, @@ -57,7 +74,7 @@ export function createReadingAnchor() { // A prepend can expose the bottom of an estimated row above the reader. // Do not transfer ownership to it until the user moves the reading position. const next = previous && !userScrolled ? previous : readingRow(correctedTop); - anchor = next ? { id: next.id, top: next.top } : null; + anchor = next ? anchorTop(next) : null; return correctedTop; }, }; diff --git a/packages/app/src/agent-stream/spacing.test.ts b/packages/app/src/agent-stream/spacing.test.ts index 07e9f185ff..3f84f0df5e 100644 --- a/packages/app/src/agent-stream/spacing.test.ts +++ b/packages/app/src/agent-stream/spacing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing"; +import { summarizeToolCalls } from "@/tool-calls/detail-level/overview/model"; import type { StreamItem } from "@/types/stream"; const message: StreamItem = { kind: "assistant_message", @@ -7,6 +8,27 @@ const message: StreamItem = { text: "First\n\nSecond", timestamp: new Date(0), }; +const prompt: StreamItem = { + kind: "user_message", + id: "p", + text: "Fix it", + timestamp: new Date(0), +}; +const thought: StreamItem = { + kind: "thought", + id: "t", + text: "Reading", + timestamp: new Date(0), + status: "ready", +}; +const summary: StreamItem = { + kind: "turn_summary", + id: "turn-summary:p", + hostId: "p", + expanded: false, + timestamp: new Date(0), + tools: summarizeToolCalls([]), +}; describe("whole-message spacing", () => { it("compacts only the gap to its turn footer", () => { expect(getAssistantMessageSpacing({ item: message })).toBe("default"); @@ -17,4 +39,18 @@ describe("whole-message spacing", () => { it("keeps separate messages separated", () => { expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0); }); + it("sets a prompt the same distance above whatever answers it", () => { + const belowPrompt = getGapBetweenStreamItems(prompt, thought); + expect(belowPrompt).toBeGreaterThan(0); + expect(getGapBetweenStreamItems(prompt, message)).toBe(belowPrompt); + expect(getGapBetweenStreamItems(prompt, summary)).toBe(belowPrompt); + }); + it("spaces a turn's summary row like the work rows it stands for", () => { + expect(getGapBetweenStreamItems(summary, message)).toBe( + getGapBetweenStreamItems(thought, message), + ); + expect(getGapBetweenStreamItems(thought, summary)).toBe( + getGapBetweenStreamItems(thought, thought), + ); + }); }); diff --git a/packages/app/src/agent-stream/spacing.ts b/packages/app/src/agent-stream/spacing.ts index 55761f71f3..3caaa9f21e 100644 --- a/packages/app/src/agent-stream/spacing.ts +++ b/packages/app/src/agent-stream/spacing.ts @@ -11,8 +11,12 @@ export function getAssistantMessageSpacing(params: { } const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message"; -const isToolSequenceItem = (item?: StreamItem | null) => - item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; +/** A row of the agent's work, including the summary row that stands in for a folded turn's. */ +const isWorkItem = (item?: StreamItem | null) => + item?.kind === "tool_call" || + item?.kind === "thought" || + item?.kind === "todo_list" || + item?.kind === "turn_summary"; export function getGapBetweenStreamItems( item: StreamItem | null, @@ -25,19 +29,16 @@ export function getGapBetweenStreamItems( if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { return SPACING[1]; } - if (item.kind === "user_message" && belowItem.kind === "assistant_message") { - return 0; - } - if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { - return 0; - } - if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { + if (isUserMessageItem(item)) { return SPACING[4]; } - if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) { + if (isWorkItem(item) && isWorkItem(belowItem)) { + return 0; + } + if (item.kind === "assistant_message" && isWorkItem(belowItem)) { return SPACING[1]; } - if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { + if (isWorkItem(item) && belowItem.kind === "assistant_message") { return SPACING[1]; } return SPACING[4]; diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 3288e2be25..106c9c3bb4 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -25,6 +25,9 @@ import { } from "@legendapp/list/react-native"; import { withUnistyles } from "react-native-unistyles"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Button } from "@/components/ui/button"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useTranslation } from "react-i18next"; import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -32,21 +35,27 @@ import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation"; import { useRevisedHistoryRows } from "./history-row-revision"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; -import { createNativeDisclosureHold } from "./native-disclosure-hold"; +import { + createNativeDisclosureHold, + disclosureAnchor, + createEndHoldDistance, + type EndHoldDistance, +} from "./native-disclosure-hold"; import { EndPinnedScrollView } from "./native-end-pin"; import { createNewRowEstimates } from "./new-row-estimates"; -import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; +import type { + DisclosureTriggerEdge, + StreamRenderInput, + StreamStrategy, + StreamViewportHandle, +} from "./strategy"; import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy"; import { - abandonHistoryStartPaginationRequest, - createHistoryStartPaginationState, - evaluateHistoryStartPagination, isHistoryStartLoadingOperation, - rearmHistoryStartPagination, - settleHistoryStartPagination, type HistoryStartPaginationInput, - type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { useHistoryStartPagination } from "./use-history-start-pagination"; +import { nativeHistoryStartGeometry } from "./native-history-start-geometry"; import { useStreamTopOverlayInset } from "./top-overlay"; import { createHistoryStartSettleScheduler, @@ -108,6 +117,16 @@ function distanceFromEnd(input: { }): number { return Math.max(0, input.contentHeight - input.viewportHeight - input.offsetY); } +const viewportStyle: ViewStyle = { flex: 1 }; +const initialFillStyle: ViewStyle = { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: "center", + justifyContent: "center", +}; function keyExtractor(item: { id: string }): string { return item.id; @@ -125,6 +144,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, + isInitialHistoryLoading, + revealInitialHistory, onNearBottomChange, onNearHistoryStart, isLoadingOlderHistory, @@ -135,6 +156,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat baseListContentContainerStyle, strategy, } = props; + const isActive = useRetainedPanelActive(); + const { t } = useTranslation(); const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers; const listRef = useRef(null); // LegendList places the rows once, behind a hidden reveal, when the list mounts. @@ -157,10 +180,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const isReleasedAtEndRef = useRef(false); const programmaticScrollEventBudgetRef = useRef(0); const historyStartReadyRef = useRef(false); - const [historyStartPaginationState, setHistoryStartPaginationState] = useState( - createHistoryStartPaginationState, - ); - const historyStartPaginationStateRef = useRef(historyStartPaginationState); const historyStartSettleSchedulerRef = useRef(null); const historyItems = useMemo(() => { @@ -191,48 +210,36 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat metrics.viewportMeasuredForKey === metrics.containerKey && metrics.contentMeasuredForKey === metrics.containerKey; return { - distanceFromHistoryStart: metrics.offsetY, + ...nativeHistoryStartGeometry({ + contentHeight: metrics.contentHeight, + viewportHeight: metrics.viewportHeight, + offsetY: metrics.offsetY, + topOverlayInset, + }), hasOlderHistory, isLoadingOlderHistory, - isReady: historyStartReadyRef.current && hasMeasuredViewport, + isReady: + isActive && + isAuthoritativeHistoryReady && + historyStartReadyRef.current && + hasMeasuredViewport, progressKey: olderHistoryProgressKey, }; }); - const applyHistoryStartPaginationTransition = useStableEvent( - (transition: HistoryStartPaginationTransition) => { - const previousState = historyStartPaginationStateRef.current; - historyStartPaginationStateRef.current = transition.state; - if (transition.state !== previousState) { - setHistoryStartPaginationState(transition.state); - } - if (transition.shouldLoad) { - const requestedProgressKey = olderHistoryProgressKey; - if (requestedProgressKey === null) { - return; - } - void (async () => { - const started = await onNearHistoryStart(); - if (started === true) { - return; - } - applyHistoryStartPaginationTransition({ - state: abandonHistoryStartPaginationRequest( - historyStartPaginationStateRef.current, - requestedProgressKey, - ), - shouldLoad: false, - }); - })(); - } - }, + const { state: historyStartPaginationState, controller: historyStartPagination } = + useHistoryStartPagination({ + initiallyVisible: !isInitialHistoryLoading, + read: getHistoryStartPaginationInput, + load: async () => onNearHistoryStart(), + scheduleSettle: () => scheduleHistoryStartSettle(), + }); + const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update()); + const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose()); + const isHistoryStartSettling = useStableEvent( + () => historyStartPagination.state.status === "settling", ); - const evaluateHistoryStart = useStableEvent(() => { - const transition = evaluateHistoryStartPagination( - historyStartPaginationStateRef.current, - getHistoryStartPaginationInput(), - ); - applyHistoryStartPaginationTransition(transition); - }); + const finishInitialFill = useStableEvent(() => historyStartPagination.reveal()); + const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm()); const scheduleHistoryStartSettle = useStableEvent(() => { let scheduler = historyStartSettleSchedulerRef.current; if (!scheduler) { @@ -240,15 +247,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat settleFrames: HISTORY_START_SETTLE_FRAMES, requestFrame: requestAnimationFrame, cancelFrame: cancelAnimationFrame, - isSettling: () => historyStartPaginationStateRef.current.status === "settling", + isSettling: () => isHistoryStartSettling(), isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory, - onSettle: () => { - const transition = settleHistoryStartPagination( - historyStartPaginationStateRef.current, - getHistoryStartPaginationInput(), - ); - applyHistoryStartPaginationTransition(transition); - }, + onSettle: () => historyStartPagination.settle(), }); historyStartSettleSchedulerRef.current = scheduler; } @@ -315,8 +316,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const isAtEnd = useStableEvent( () => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX, ); + // A hold on the content end mounts the end pin while detached (`holdRow`). The release reads the + // tracked distance instead of the offset: the content size reaches JS before the pin's scroll does. + const [endHold, setEndHold] = useState<{ rowId: string; distance: EndHoldDistance } | null>(null); const releaseDisclosure = useStableEvent(() => { - bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() }); + setEndHold(null); + bottomAnchorController.releaseDisclosure({ + isAtEnd: endHold ? endHold.distance.current() <= AT_END_OFFSET_PX : isAtEnd(), + }); }); const [disclosureHold] = useState(() => createNativeDisclosureHold({ @@ -326,16 +333,54 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onRelease: releaseDisclosure, }), ); - useEffect(() => () => disclosureHold.cancel(), [disclosureHold]); - // The hold detaches the controller in the same event as `apply`, so the toggle's commit already - // has end following off and LegendList anchoring on the held row. The row's measurement trigger - // makes that same commit carry the row's new height. - const holdRow = useStableEvent((rowId: string, apply: () => void) => { - disclosureHold.hold(rowId); - bottomAnchorController.holdForDisclosure(); - apply(); - rowLayoutSyncRegistry.get(rowId)?.(); + const cancelDisclosureHold = useStableEvent(() => { + disclosureHold.cancel(); + setEndHold(null); }); + useEffect(() => () => cancelDisclosureHold(), [cancelDisclosureHold]); + // The hold detaches the controller in the same event as `apply`, so the toggle's commit already + // has end following off and LegendList anchoring on the held row, or on the row below it for a + // trigger on the row's bottom edge. The row's measurement trigger makes that same commit carry + // the row's new height. While following, a bottom trigger keeps following: the end pin holds the + // content end, and with it the bottom of the row, in the frame the new height mounts. + // The last row's bottom is the content end, which LegendList cannot anchor. The end pin can, from + // any offset, but only once it is mounted: a pin that mounts with the new height anchors nothing. + // So the toggle lands a frame after the pin's commit, whose mount runs before the frame's timers. + const latestDisclosureAnchor = useStableEvent( + (rowId: string, triggerEdge: DisclosureTriggerEdge) => + disclosureAnchor(rows, rowId, triggerEdge), + ); + const holdRow = useStableEvent( + (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => { + const applyAndMeasure = () => { + apply(); + rowLayoutSyncRegistry.get(rowId)?.(); + }; + if (triggerEdge === "bottom" && bottomAnchorController.mode === "sticky-bottom") { + applyAndMeasure(); + return; + } + const anchor = disclosureAnchor(rows, rowId, triggerEdge); + bottomAnchorController.holdForDisclosure(); + if (anchor.kind === "row" || !isListPlacedRef.current) { + setEndHold(null); + disclosureHold.hold(anchor); + applyAndMeasure(); + return; + } + setEndHold({ + rowId, + distance: createEndHoldDistance(distanceFromEnd(streamViewportMetricsRef.current)), + }); + disclosureHold.holdNextFrame( + () => latestDisclosureAnchor(rowId, triggerEdge), + (latestAnchor) => { + if (latestAnchor.kind === "row") setEndHold(null); + applyAndMeasure(); + }, + ); + }, + ); const handleItemSizeChanged = useStableEvent( (info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => { newRowEstimates.noteMeasured(info.itemData, info.size); @@ -343,8 +388,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }, ); const shouldRestorePosition = useStableEvent((item: StreamItem) => { - const heldRowId = disclosureHold.heldRowId(); - return heldRowId === null || item.id === heldRowId; + const anchorRowId = disclosureHold.anchorRowId(); + return anchorRowId === null || item.id === anchorRowId; }); // One owner keeps the scroll offset at any moment: // - Before the list has placed its rows for the agent: LegendList's initial scroll to the end. It @@ -358,16 +403,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat // scrolls to the end its JS sizes predict, which runs ahead of the native mount, so it lands // short of the real end and the controller reads that as the user scrolling away. // - A disclosure hold: the held row. The hold detaches the controller, which removes the pin, and - // LegendList keeps only the held row in place. + // LegendList keeps only the held row in place, or the row below it when the trigger sits on the + // held row's bottom edge. A bottom-edge hold while following leaves the end pin in charge, and + // one on the last row mounts the pin for the hold: the row's bottom is the content end. // - A user scroll: the finger. The controller detaches for the gesture, so streamed lines grow below // the rows the user is dragging, and resumes following if the gesture ends at the end. // - Detached: the visible rows. LegendList keeps the first visible row in place, so history loading // above or rows growing below leave what the user is reading where it is. const isFollowingEnd = bottomAnchorController.mode === "sticky-bottom"; - const pinsEnd = isFollowingEnd && isListPlaced; + // An end hold pins the end only while its row's bottom is the end. A reply landing below the row + // drops the pin in the commit that mounts the reply, so the reply grows below the held row. + const isEndHeld = + endHold !== null && disclosureAnchor(rows, endHold.rowId, "bottom").kind === "end"; + const isEndPinned = isFollowingEnd || isEndHeld; + const pinsEnd = isEndPinned && isListPlaced; const maintainVisibleContentPosition = useMemo( - () => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }), - [isFollowingEnd, shouldRestorePosition], + () => (isEndPinned ? false : { data: true, size: true, shouldRestorePosition }), + [isEndPinned, shouldRestorePosition], ); // A new identity re-renders LegendList's scroll component, which is how the pin follows the mode. const renderScrollComponent = useCallback( @@ -390,10 +442,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat return () => { cancelAnimationFrame(frame); clearPendingUserScrollEnd(); + disposeHistoryStart(); historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [clearPendingUserScrollEnd, evaluateHistoryStart]); + }, [clearPendingUserScrollEnd, disposeHistoryStart, evaluateHistoryStart]); useEffect(() => { const handle: StreamViewportHandle = { @@ -506,22 +559,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent) => { + finishInitialFill(); clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; scrollKeyboardDismiss.onScrollBeginDrag(event); // The gesture takes the viewport from a disclosure hold. Size changes from a streaming row can // keep the hold's quiet frames from ever passing, and a held row that scrolls away would leave // no row LegendList may anchor a history page on. The gesture's end decides following. - disclosureHold.cancel(); + cancelDisclosureHold(); bottomAnchorController.beginUserScroll(); - const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); - if (rearmed !== historyStartPaginationStateRef.current) { - historyStartPaginationStateRef.current = rearmed; - setHistoryStartPaginationState(rearmed); - evaluateHistoryStart(); - } + historyStartPagination.rearm(); }); + useEffect(() => { + if (revealInitialHistory) finishInitialFill(); + }, [finishInitialFill, revealInitialHistory]); + // A gesture ends at the end when the finger lifts at or past it, or when its momentum stops there. // A release past the end bounces back to it on iOS, and lines that land during the bounce scroll // in as they do while following. Momentum stopping short of the end, or output that arrived below @@ -589,6 +642,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentHeight: nextContentHeight, contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null, }; + endHold?.distance.noteContentSize(streamViewportMetricsRef.current); bottomAnchorController.handleContentSizeChange(); // Content growing below a detached viewport moves the end away without a scroll event. if ( @@ -598,7 +652,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat publishNearBottom(); } evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartSettle(); } }); @@ -611,19 +665,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: "native-virtualized", }; bottomAnchorController.handleContentSizeChange(); + evaluateHistoryStart(); }); useEffect(() => { evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartSettle(); } }, [ evaluateHistoryStart, hasOlderHistory, + isActive, + isAuthoritativeHistoryReady, isLoadingOlderHistory, olderHistoryProgressKey, scheduleHistoryStartSettle, + isHistoryStartSettling, ]); // LegendList re-renders a mounted row only when its item or `extraData` changes, not when @@ -658,51 +716,65 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return ( - {isLoadingOperation ? ( + {isLoadingOperation && ( - ) : null} + )} + {!isLoadingOperation && + historyStartPaginationState.status === "failed" && + hasOlderHistory && ( + + )} ); - }, [historyStartPaginationState]); + }, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]); return ( - - ref={listRef} - data={rows} - renderItem={renderItem} - extraData={renderHistoryMountedRow} - keyExtractor={keyExtractor} - testID="agent-chat-scroll" - nativeID="agent-chat-scroll-native-virtualized" - ListHeaderComponent={historyStartContent} - ListFooterComponent={liveAuxiliaryContent ?? undefined} - contentContainerStyle={listContentContainerStyle} - automaticallyAdjustContentInsets={false} - automaticallyAdjustsScrollIndicatorInsets={false} - contentInsetAdjustmentBehavior="never" - style={listStyle} - onLayout={handleListLayout} - onScroll={handleScroll} - onScrollBeginDrag={handleScrollBeginDrag} - onScrollEndDrag={handleScrollEndDrag} - onMomentumScrollBegin={handleMomentumScrollBegin} - onMomentumScrollEnd={handleMomentumScrollEnd} - scrollEventThrottle={16} - onContentSizeChange={handleContentSizeChange} - onItemSizeChanged={handleItemSizeChanged} - onReady={handleListPlaced} - maintainVisibleContentPosition={maintainVisibleContentPosition} - renderScrollComponent={renderScrollComponent} - recycleItems={false} - alignItemsAtEnd - initialScrollAtEnd - drawDistance={NATIVE_STREAM_DRAW_DISTANCE} - getEstimatedItemSize={getEstimatedItemSize} - scrollEnabled={scrollEnabled} - showsVerticalScrollIndicator - /> + + + ref={listRef} + data={rows} + renderItem={renderItem} + extraData={renderHistoryMountedRow} + keyExtractor={keyExtractor} + testID="agent-chat-scroll" + nativeID="agent-chat-scroll-native-virtualized" + ListHeaderComponent={historyStartContent} + ListFooterComponent={liveAuxiliaryContent ?? undefined} + contentContainerStyle={listContentContainerStyle} + automaticallyAdjustContentInsets={false} + automaticallyAdjustsScrollIndicatorInsets={false} + contentInsetAdjustmentBehavior="never" + style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]} + onLayout={handleListLayout} + onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} + onMomentumScrollEnd={handleMomentumScrollEnd} + scrollEventThrottle={16} + onContentSizeChange={handleContentSizeChange} + onItemSizeChanged={handleItemSizeChanged} + onReady={handleListPlaced} + maintainVisibleContentPosition={maintainVisibleContentPosition} + renderScrollComponent={renderScrollComponent} + recycleItems={false} + alignItemsAtEnd + initialScrollAtEnd + drawDistance={NATIVE_STREAM_DRAW_DISTANCE} + getEstimatedItemSize={getEstimatedItemSize} + scrollEnabled={scrollEnabled} + showsVerticalScrollIndicator + /> + {!historyStartPaginationState.visible ? ( + + + + ) : null} + ); } diff --git a/packages/app/src/agent-stream/strategy-web.browser.test.tsx b/packages/app/src/agent-stream/strategy-web.browser.test.tsx index 28fbd1e2a2..12127ea630 100644 --- a/packages/app/src/agent-stream/strategy-web.browser.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.browser.test.tsx @@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme"; import type { StreamItem } from "@/types/stream"; -import type { StreamRenderInput, StreamViewportHandle } from "./strategy"; +import type { DisclosureTriggerEdge, StreamRenderInput, StreamViewportHandle } from "./strategy"; import { createWebStreamStrategy } from "./strategy-web"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -67,7 +67,7 @@ function mountTimeline(historyMounted: StreamItem[]) { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: () => {}, - onNearHistoryStart: () => true, + onNearHistoryStart: async () => true, isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -91,10 +91,15 @@ function mountTimeline(historyMounted: StreamItem[]) { return { render: (segments: Partial) => act(() => render(segments)), - hold: (rowId: string, apply: () => void) => - act(() => viewportRef.current!.holdRow(rowId, apply)), + hold: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge = "top") => + act(() => viewportRef.current!.holdRow(rowId, apply, triggerEdge)), rowTop: (id: string) => rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, + rowBottom: (id: string) => + rowElement(id).getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().top, + scrollTo: (top: number) => { + scrollContainer.scrollTop = top; + }, distanceFromEnd: () => scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop, // Resize observers and the hold's settle run on animation frames. @@ -139,6 +144,53 @@ describe("web disclosure hold", () => { expect(timeline.rowTop("message-5")).toBe(triggerTop); }); + // A long message's show more sits under the text it opens, on the bottom edge of its row. + it("keeps the bottom of a bottom-trigger row through show more and show less while following output", async () => { + const history = [1, 2, 3, 4, 5, 6].map((index) => row(index)); + const timeline = mountTimeline(history); + await timeline.settle(); + const triggerBottom = timeline.rowBottom("message-5"); + + timeline.hold( + "message-5", + () => timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }), + "bottom", + ); + await timeline.settle(); + expect(timeline.rowBottom("message-5")).toBe(triggerBottom); + + timeline.hold("message-5", () => timeline.render({ historyMounted: history }), "bottom"); + await timeline.settle(); + expect(timeline.rowBottom("message-5")).toBe(triggerBottom); + }); + + it("keeps the bottom of a bottom-trigger row through show more and show less when scrolled up", async () => { + const history = [1, 2, 3, 4, 5, 6].map((index) => row(index)); + const timeline = mountTimeline(history); + await timeline.settle(); + timeline.scrollTo(60); + await timeline.settle(); + // message-2 is cut off at the top; message-3 is fully in view. + for (const index of [2, 3]) { + const triggerBottom = timeline.rowBottom(`message-${index}`); + const grown = history.map((item, position) => + position === index - 1 ? row(index, 240) : item, + ); + + timeline.hold(`message-${index}`, () => timeline.render({ historyMounted: grown }), "bottom"); + await timeline.settle(); + expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom); + + timeline.hold( + `message-${index}`, + () => timeline.render({ historyMounted: history }), + "bottom", + ); + await timeline.settle(); + expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom); + } + }); + it("resumes following when collapsing an anchored row lands back at the end", async () => { const history = [1, 2, 3, 4, 5, 6].map((index) => row(index)); const timeline = mountTimeline(history); diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 5ad0be03c2..3601c8b0b2 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: () => {}, - onNearHistoryStart: () => true, + onNearHistoryStart: async () => true, isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: () => {}, - onNearHistoryStart: () => true, + onNearHistoryStart: async () => true, isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index b511ac72dd..c47e0720f4 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { useTranslation } from "react-i18next"; import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; @@ -30,15 +31,10 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./ import { useRevisedHistoryRows } from "./history-row-revision"; import { createStreamStrategy } from "./strategy"; import { - abandonHistoryStartPaginationRequest, - createHistoryStartPaginationState, - evaluateHistoryStartPagination, isHistoryStartLoadingOperation, - rearmHistoryStartPagination, - settleHistoryStartPagination, type HistoryStartPaginationInput, - type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { useHistoryStartPagination } from "./use-history-start-pagination"; import { createHistoryStartSettleScheduler, type HistoryStartSettleScheduler, @@ -77,7 +73,17 @@ const historyStartSlotStyle: CSSProperties = { }; const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" }; +const initialFillStyle: CSSProperties = { + position: "absolute", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + pointerEvents: "none", +}; +// Rows and the live auxiliary (turn footer, pending permissions) center their content with +// `alignSelf`, which only applies inside a flex container. const streamRowStyle: CSSProperties = { display: "flex", flexDirection: "column", @@ -298,6 +304,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, + isInitialHistoryLoading, + revealInitialHistory, onNearBottomChange, onReadingPositionChange, onNearHistoryStart, @@ -318,6 +326,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool [historyMounted, historyVirtualized, inputSegments], ); const isActive = useRetainedPanelActive(); + const { t } = useTranslation(); + const rowsHostRef = useRef(null); + const auxiliaryRef = useRef(null); const isActiveRef = useRef(isActive); const scrollContainerRef = useRef(null); const contentRef = useRef(null); @@ -351,10 +362,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); const historyStartReadyRef = useRef(false); - const [historyStartPaginationState, setHistoryStartPaginationState] = useState( - createHistoryStartPaginationState, - ); - const historyStartPaginationStateRef = useRef(historyStartPaginationState); const readingAnchor = useMemo(createReadingAnchor, []); const measuredRowHeights = useRef(new Map()); const historyStartSettleSchedulerRef = useRef(null); @@ -411,6 +418,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool { id: String(measurement.key), top: measurement.start, + height: measurement.size, }, props.readingRowReplacements, ) @@ -481,60 +489,36 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !followOutputRef.current || isScrollContainerNearBottom(scrollContainer); return { distanceFromHistoryStart: scrollContainer.scrollTop, + isUnderfilled: + (rowsHostRef.current?.getBoundingClientRect().height ?? 0) + + (auxiliaryRef.current?.getBoundingClientRect().height ?? 0) < + scrollContainer.clientHeight, hasOlderHistory, isLoadingOlderHistory, // An explicit jump owns the landing position. Revealing another local // page here changes the virtual index before the target has mounted. - isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(), + isReady: + historyStartReadyRef.current && + isAuthoritativeHistoryReady && + bottomAnchorSettled && + !isJumpSettling(), progressKey: olderHistoryProgressKey, }; }); - const applyHistoryStartPaginationTransition = useStableEvent( - (transition: HistoryStartPaginationTransition) => { - const previousState = historyStartPaginationStateRef.current; - historyStartPaginationStateRef.current = transition.state; - if (transition.state !== previousState) { - setHistoryStartPaginationState(transition.state); - } - if (!transition.shouldLoad || olderHistoryProgressKey === null) { - return; - } - const requestedProgressKey = olderHistoryProgressKey; - void (async () => { - const started = await onNearHistoryStart(); - if (started === true) { - return; - } - applyHistoryStartPaginationTransition({ - state: abandonHistoryStartPaginationRequest( - historyStartPaginationStateRef.current, - requestedProgressKey, - ), - shouldLoad: false, - }); - })(); - }, + const { state: historyStartPaginationState, controller: historyStartPagination } = + useHistoryStartPagination({ + initiallyVisible: !isInitialHistoryLoading, + read: getHistoryStartPaginationInput, + load: async () => onNearHistoryStart(), + scheduleSettle: () => scheduleHistoryStartPrependSettle(), + }); + const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update()); + const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose()); + const isHistoryStartSettling = useStableEvent( + () => historyStartPagination.state.status === "settling", ); - const evaluateHistoryStart = useStableEvent(() => { - const input = getHistoryStartPaginationInput(); - if (!input) { - return; - } - const transition = evaluateHistoryStartPagination( - historyStartPaginationStateRef.current, - input, - ); - applyHistoryStartPaginationTransition(transition); - }); - const rearmHistoryStartFromUserIntent = useStableEvent(() => { - const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); - if (rearmed === historyStartPaginationStateRef.current) { - return; - } - historyStartPaginationStateRef.current = rearmed; - setHistoryStartPaginationState(rearmed); - evaluateHistoryStart(); - }); + const finishInitialFill = useStableEvent(() => historyStartPagination.reveal()); + const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm()); const scheduleHistoryStartPrependSettle = useStableEvent(() => { let scheduler = historyStartSettleSchedulerRef.current; if (!scheduler) { @@ -542,22 +526,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool settleFrames: HISTORY_START_SETTLE_FRAMES, requestFrame: (callback) => window.requestAnimationFrame(callback), cancelFrame: (frame) => window.cancelAnimationFrame(frame), - isSettling: () => historyStartPaginationStateRef.current.status === "settling", + isSettling: () => isHistoryStartSettling(), isLoading: () => { const input = getHistoryStartPaginationInput(); return !input || input.isLoadingOlderHistory; }, - onSettle: () => { - const input = getHistoryStartPaginationInput(); - if (!input) { - return; - } - const transition = settleHistoryStartPagination( - historyStartPaginationStateRef.current, - input, - ); - applyHistoryStartPaginationTransition(transition); - }, + onSettle: () => historyStartPagination.settle(), }); historyStartSettleSchedulerRef.current = scheduler; } @@ -794,6 +768,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]); const stopFollowingOutputFromUserIntent = useStableEvent(() => { + finishInitialFill(); cancelPendingStickToBottom(); if (followOutputRef.current) { setFollowOutput(false); @@ -801,6 +776,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool rearmHistoryStartFromUserIntent(); }); + useEffect(() => { + if (revealInitialHistory) finishInitialFill(); + }, [finishInitialFill, revealInitialHistory]); + const handleDomScroll = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) { @@ -846,10 +825,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { window.cancelAnimationFrame(frame); historyStartReadyRef.current = false; + disposeHistoryStart(); historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [evaluateHistoryStart]); + }, [disposeHistoryStart, evaluateHistoryStart]); useLayoutEffect(() => { if (!isActiveRef.current || !isActivationReady) { @@ -938,16 +918,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool updateScrollMetrics(); } evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartPrependSettle(); } }, [ evaluateHistoryStart, hasOlderHistory, isActive, + isAuthoritativeHistoryReady, isLoadingOlderHistory, olderHistoryProgressKey, scheduleHistoryStartPrependSettle, + isHistoryStartSettling, segments.historyMounted.length, segments.historyVirtualized.length, segments.liveHead.length, @@ -986,7 +968,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool lastObservedViewportGeometryRef.current = nextGeometry; } reconcileReadingPosition(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartPrependSettle(); } updateScrollMetrics(); @@ -1020,6 +1002,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool isActive, reportReadingPosition, scheduleHistoryStartPrependSettle, + isHistoryStartSettling, forceStickToBottom, updateScrollMetrics, measureVirtualizedRows, @@ -1190,11 +1173,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } scheduleStickToBottom(); }, - holdRow: (rowId, apply) => { + holdRow: (rowId, apply, triggerEdge) => { cancelPendingStickToBottom(); setFollowOutput(false); const row = measureRows()?.find((candidate) => candidate.id === rowId); - if (row) readingAnchor.pin(row); + const scrollTop = scrollContainerRef.current?.scrollTop; + if (row && scrollTop !== undefined) readingAnchor.pin(row, triggerEdge, scrollTop); disclosurePendingRef.current = true; apply(); }, @@ -1241,8 +1225,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool // Detached reading has one owner: reconcileReadingPosition. overflowAnchor: followOutput ? "auto" : "none", scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined, + opacity: historyStartPaginationState.visible ? 1 : 0, }; - }, [followOutput, isMobileBreakpoint, scrollEnabled]); + }, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]); const viewportStyle = useMemo( (): CSSProperties => ({ position: "relative", @@ -1319,14 +1304,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return (
- {isLoadingOperation ? ( + {isLoadingOperation && (
- ) : null} + )} + {!isLoadingOperation && + historyStartPaginationState.status === "failed" && + hasOlderHistory && ( + + )}
); - }, [historyStartPaginationState]); + }, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && @@ -1346,7 +1338,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool >
{historyStartSlot} -
+
{[ ...virtualRows.map((virtualRow) => { @@ -1374,10 +1366,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ...mountedRows, ]}
- {liveAuxiliary} +
+ {liveAuxiliary} +
{shouldRenderEmpty ? listEmptyComponent : null}
+ {!historyStartPaginationState.visible ? ( +
+ +
+ ) : null} {scrollEnabled && !isMobileBreakpoint ? ( void; scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; /** - * Run a disclosure's state change so the row that owns its trigger keeps its visual top. - * Following output stops; it resumes if the change leaves the list at its end. + * Run a disclosure's state change so the row that owns its trigger keeps the edge the trigger + * sits on in place. Following output stops; it resumes if the change leaves the list at its end. */ - holdRow: (rowId: string, apply: () => void) => void; + holdRow: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => void; scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void; } @@ -85,11 +92,13 @@ export interface StreamRenderInput { viewportRef: RefObject; routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; + isInitialHistoryLoading?: boolean; + revealInitialHistory?: boolean; onNearBottomChange: (value: boolean) => void; // The history row under the top of the viewport, for surfaces that mark where the reader // is in the transcript. Only the web viewport measures it today. onReadingPositionChange?: (rowId: string | null) => void; - onNearHistoryStart: () => boolean | Promise; + onNearHistoryStart: () => Promise; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; olderHistoryProgressKey: string | null; diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index 4fab619628..9c48f5695a 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -56,7 +56,7 @@ function collapse(tail: StreamItem[], options: Partial = {}) isLatestTurnOpen: false, expandedHostIds: new Set(), getGroupedCalls: () => undefined, - previousTurnsByHostId: new Map(), + previousSummaryRowsByHostId: new Map(), ...options, }); } @@ -73,17 +73,29 @@ describe("collapseTurns", () => { pluginReply("final", "right"), ]; const result = collapse(items); - expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]); + expect(ids(result.tail)).toEqual([ + "prompt", + "turn-summary:prompt", + "math/final/left", + "math/final/right", + ]); expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt"); expect(result.hostIdByFoldedMessageId.has("final")).toBe(false); const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) }); - expect(expanded.tail).toEqual(items); + expect(ids(expanded.tail)).toEqual([ + "prompt", + "turn-summary:prompt", + "acknowledgment", + "call", + "math/final/left", + "math/final/right", + ]); expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]); }); it("folds intermediate plugin replies even without tool calls", () => { const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]); - expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]); + expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "math/final/0"]); expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt"); }); @@ -93,7 +105,7 @@ describe("collapseTurns", () => { const card = { ...pluginReply("card"), sourceKind }; const items = [user("prompt"), shell("call"), card]; expect(collapse(items).tail).toEqual(items); - expect(collapse(items).turnsByHostId.size).toBe(0); + expect(collapse(items).summaryRowsByHostId.size).toBe(0); }, ); @@ -102,38 +114,41 @@ describe("collapseTurns", () => { expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items); }); - // A history row renders again only when its item changes, so a prompt that is already retained - // when its turn closes needs its row revised to show the summary line. - it("reports the hosts whose summary line appeared, changed or went away", () => { - const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], { + // A mounted row renders again only when its item changes, so a turn whose work and open state + // are unchanged keeps its summary row object across folds of the same history. + it("keeps a summary row until its turn's work or open state changes", () => { + const first = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], { isLatestTurnOpen: true, }); - expect([...open.updatedHostIds]).toEqual(["u1"]); const closed = collapse( [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")], - { - previousTurnsByHostId: open.turnsByHostId, - }, + { previousSummaryRowsByHostId: first.summaryRowsByHostId }, ); - expect([...closed.updatedHostIds]).toEqual(["u2"]); - const unchanged = collapse( - [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")], - { - previousTurnsByHostId: closed.turnsByHostId, - }, - ); - expect(unchanged.updatedHostIds.size).toBe(0); + expect(closed.summaryRowsByHostId.get("u1")).toBe(first.summaryRowsByHostId.get("u1")); const moreWork = collapse( [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")], - { previousTurnsByHostId: unchanged.turnsByHostId }, + { previousSummaryRowsByHostId: closed.summaryRowsByHostId }, ); - expect([...moreWork.updatedHostIds]).toEqual(["u2"]); - const rewound = collapse([user("u1"), shell("c1"), reply("r1")], { - previousTurnsByHostId: moreWork.turnsByHostId, + expect(moreWork.summaryRowsByHostId.get("u1")).toBe(closed.summaryRowsByHostId.get("u1")); + expect(moreWork.summaryRowsByHostId.get("u2")).not.toBe(closed.summaryRowsByHostId.get("u2")); + const opened = collapse(moreWork.tail, { + previousSummaryRowsByHostId: moreWork.summaryRowsByHostId, + expandedHostIds: new Set(["u1"]), }); - expect([...rewound.updatedHostIds]).toEqual(["u2"]); + expect(opened.summaryRowsByHostId.get("u1")).not.toBe(moreWork.summaryRowsByHostId.get("u1")); }); - it("maps hidden rows only while their turn is closed", () => { + it("stands at its host's timeline position, taking a new row when that position lands", () => { + const live = collapse([user("u1"), shell("c1"), reply("r1")]); + expect(live.summaryRowsByHostId.get("u1")).not.toHaveProperty("timelineCursor"); + const cursor = { epoch: "epoch-1", seq: 7 }; + const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")]; + const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId }); + expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1")); + expect(canonical.summaryRowsByHostId.get("u1")?.timelineCursor).toEqual(cursor); + const again = collapse(placed, { previousSummaryRowsByHostId: canonical.summaryRowsByHostId }); + expect(again.summaryRowsByHostId.get("u1")).toBe(canonical.summaryRowsByHostId.get("u1")); + }); + it("maps hidden rows to their turn's host only while the turn is closed", () => { const result = collapse([user("u1"), shell("c1"), reply("final")]); expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1"); const expanded = collapse([user("u1"), shell("c1"), reply("final")], { @@ -168,15 +183,15 @@ describe("collapseTurns", () => { reply("done"), ].map((item) => Object.assign(item, { turnId: "same-run" })); const result = collapse(items); - expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]); - expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({ + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "finished", "compaction", "done"]); + expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 1, otherToolCount: 0, paseoActions: { "Created agent": 1 }, }); }); - it("folds a finished turn's work into its user message and keeps the reply", () => { + it("folds a finished turn's work into a summary row under its user message and keeps the reply", () => { const result = collapse([ user("u1"), thought("t1"), @@ -186,14 +201,16 @@ describe("collapseTurns", () => { reply("final"), ]); - expect(ids(result.tail)).toEqual(["u1", "final"]); - expect(result.turnsByHostId.get("u1")).toEqual({ + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "final"]); + expect(result.tail[1]).toEqual({ + kind: "turn_summary", + id: "turn-summary:u1", hostId: "u1", + timestamp: at, expanded: false, - summary: { - tools: expect.objectContaining({ commandCount: 2 }), - }, + tools: expect.objectContaining({ commandCount: 2 }), }); + expect(result.summaryRowsByHostId.get("u1")).toBe(result.tail[1]); expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1"); }); @@ -202,9 +219,9 @@ describe("collapseTurns", () => { expandedHostIds: new Set(["u1"]), }); - expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]); + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "t1", "c1", "final"]); expect([...result.nestedItemIds]).toEqual(["t1", "c1"]); - expect(result.turnsByHostId.get("u1")?.expanded).toBe(true); + expect(result.summaryRowsByHostId.get("u1")?.expanded).toBe(true); }); it("leaves the latest turn alone while it is still producing output", () => { @@ -212,8 +229,8 @@ describe("collapseTurns", () => { const result = collapse(tail, { isLatestTurnOpen: true }); - expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]); - expect([...result.turnsByHostId.keys()]).toEqual(["u1"]); + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "u2", "c2", "r2"]); + expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]); }); it("keeps rows after the reply, and turns with no work before the reply, as they are", () => { @@ -226,8 +243,8 @@ describe("collapseTurns", () => { reply("r2"), ]); - expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]); - expect([...result.turnsByHostId.keys()]).toEqual(["u1"]); + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "after", "u2", "r2"]); + expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]); }); it("does not fold a turn that only thought before its reply", () => { @@ -236,7 +253,7 @@ describe("collapseTurns", () => { const result = collapse(tail); expect(result.tail).toBe(tail); - expect(result.turnsByHostId.size).toBe(0); + expect(result.summaryRowsByHostId.size).toBe(0); }); it("does not fold a turn without a reply or whose prompt is above the loaded history", () => { @@ -245,7 +262,7 @@ describe("collapseTurns", () => { const result = collapse(tail); expect(result.tail).toBe(tail); - expect(result.turnsByHostId.size).toBe(0); + expect(result.summaryRowsByHostId.size).toBe(0); }); it("keeps a completed response folded when a promptless canonical turn starts", () => { @@ -254,15 +271,15 @@ describe("collapseTurns", () => { ); const next = { ...shell("heartbeat"), turnId: "second" }; const result = collapse([...first, next], { isLatestTurnOpen: true }); - expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]); - expect(result.turnsByHostId.has("u1")).toBe(true); + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "heartbeat"]); + expect(result.tail[1]).toMatchObject({ kind: "turn_summary", turnId: "first" }); }); it("keeps mid-turn messages visible when work is folded", () => { const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")]; for (const item of tail) item.turnId = "same-turn"; const result = collapse(tail); - expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]); + expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "steer", "done"]); expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false); }); @@ -272,7 +289,7 @@ describe("collapseTurns", () => { expandedHostIds: new Set(["prompt"]), getGroupedCalls: (id) => (id === "one" ? calls : undefined), }); - expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]); + expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "one", "two", "done"]); expect([...result.nestedItemIds]).toEqual(["one", "two"]); }); @@ -282,8 +299,6 @@ describe("collapseTurns", () => { getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined), }); - expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({ - tools: { commandCount: 3 }, - }); + expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 3 }); }); }); diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index 0821c74401..fe931afed5 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -3,35 +3,25 @@ import { getStreamItemMessageId, type StreamItem, type ThoughtItem, + type TimelinePosition, type ToolCallItem, + type TurnSummaryItem, } from "@/types/stream"; import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model"; -export interface TurnSummary { - tools: OverviewSummary; -} - -export interface CollapsedTurn { - /** The input or first loaded row that carries the turn's summary line. */ - hostId: string; - expanded: boolean; - summary: TurnSummary; -} - export interface TurnCollapse { tail: StreamItem[]; - turnsByHostId: ReadonlyMap; /** Rows of expanded turns, which render inside the turn's indent rail. */ nestedItemIds: ReadonlySet; - /** The turn a folded message belongs to, for surfaces that must reveal it. */ + /** The host of the turn a folded message belongs to, for surfaces that must reveal it. */ hostIdByFoldedMessageId: ReadonlyMap; /** Mounted rows replaced by a closed turn's host, for preserving the reading position. */ hostIdByHiddenRowId: ReadonlyMap; /** - * Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A - * history row renders again only when its item changes, so these rows are revised. + * The summary rows by host. A mounted row renders again only when its item changes, so a fold + * keeps the previous row object for a turn whose work and open state are unchanged. */ - updatedHostIds: ReadonlySet; + summaryRowsByHostId: ReadonlyMap; } export interface CollapseTurnsInput { @@ -41,39 +31,39 @@ export interface CollapseTurnsInput { expandedHostIds: ReadonlySet; /** The calls a tool-call group row stands for; undefined for a single call. */ getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined; - /** The previous fold of the same history, the baseline for `updatedHostIds`. */ - previousTurnsByHostId: ReadonlyMap; + /** The previous fold of the same history, whose unchanged summary rows are reused. */ + previousSummaryRowsByHostId: ReadonlyMap; } -const EMPTY_TURNS: ReadonlyMap = new Map(); const EMPTY_IDS: ReadonlySet = new Set(); +const EMPTY_ROWS: ReadonlyMap = new Map(); const EMPTY_HOSTS: ReadonlyMap = new Map(); function summarizeTurn( items: readonly StreamItem[], getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], -): TurnSummary { +): OverviewSummary { const calls: (ToolCallItem | ThoughtItem)[] = []; for (const item of items) { if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item)) calls.push(...(getGroupedCalls(item.id) ?? [item])); } - return { tools: summarizeToolCalls(calls) }; + return summarizeToolCalls(calls); } -function hasWork(summary: TurnSummary): boolean { - const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools; +function hasWork(tools: OverviewSummary): boolean { + const { paseoActions, thoughtCount: _thoughtCount, ...counts } = tools; return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0); } -function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean { - const { paseoActions: actionsA, ...countsA } = a.summary.tools; - const { paseoActions: actionsB, ...countsB } = b.summary.tools; - return ( - a.expanded === b.expanded && - hasSameCounts(countsA, countsB) && - hasSameCounts(actionsA, actionsB) - ); +function hasSameWork(a: OverviewSummary, b: OverviewSummary): boolean { + const { paseoActions: actionsA, ...countsA } = a; + const { paseoActions: actionsB, ...countsB } = b; + return hasSameCounts(countsA, countsB) && hasSameCounts(actionsA, actionsB); +} + +function hasSamePosition(a: TimelinePosition | undefined, b: TimelinePosition | undefined) { + return a === b || (a !== undefined && b !== undefined && a.epoch === b.epoch && a.seq === b.seq); } function hasSameCounts(a: Record, b: Record): boolean { @@ -81,19 +71,32 @@ function hasSameCounts(a: Record, b: Record): bo return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); } -function findUpdatedHosts( - previous: ReadonlyMap, - next: ReadonlyMap, -): ReadonlySet { - const updated = new Set(); - for (const [hostId, turn] of next) { - const before = previous.get(hostId); - if (!before || !isSameTurn(before, turn)) updated.add(hostId); +function summaryRow( + host: StreamItem, + expanded: boolean, + tools: OverviewSummary, + previous: TurnSummaryItem | undefined, +): TurnSummaryItem { + if ( + previous && + previous.expanded === expanded && + hasSameWork(previous.tools, tools) && + hasSamePosition(previous.timelineCursor, host.timelineCursor) + ) { + return previous; } - for (const hostId of previous.keys()) { - if (!next.has(hostId)) updated.add(hostId); - } - return updated.size === 0 ? EMPTY_IDS : updated; + return { + kind: "turn_summary", + id: `turn-summary:${host.id}`, + // The row stands at its host's place in the timeline, so the reading position over it + // still resolves to the turn's prompt. + ...(host.timelineCursor !== undefined ? { timelineCursor: host.timelineCursor } : {}), + ...(host.turnId !== undefined ? { turnId: host.turnId } : {}), + timestamp: host.timestamp, + hostId: host.id, + expanded, + tools, + }; } function staysVisibleWhenCollapsed(item: StreamItem): boolean { @@ -124,15 +127,15 @@ function findLastReplyIndex(tail: StreamItem[], start: number, end: number): num } /** - * Folds each answered turn into its user message: everything the agent did before its last - * message collapses into one summary line, and that message stays in the chat. A turn folds - * only once it is complete, its user message is loaded, and it sent messages or called tools - * before its reply. Reasoning is revealed only when the turn opens. + * Folds each answered turn behind a summary row under its user message: everything the agent + * did before its last message collapses into that row, and the last message stays in the chat. + * A turn folds only once it is complete, its user message is loaded, and it sent messages or + * called tools before its reply. Reasoning is revealed only when the turn opens. */ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const { tail } = input; const output: StreamItem[] = []; - const turnsByHostId = new Map(); + const summaryRowsByHostId = new Map(); const nestedItemIds = new Set(); const hostIdByFoldedMessageId = new Map(); const hostIdByHiddenRowId = new Map(); @@ -152,13 +155,19 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { return; } const folded = tail.slice(runStart, replyIndex); - const summary = summarizeTurn(folded, input.getGroupedCalls); - if (!hasWork(summary) && !folded.some(isAssistantReply)) { + const tools = summarizeTurn(folded, input.getGroupedCalls); + if (!hasWork(tools) && !folded.some(isAssistantReply)) { output.push(...tail.slice(runStart, runEnd)); return; } const expanded = input.expandedHostIds.has(host.id); - turnsByHostId.set(host.id, { hostId: host.id, expanded, summary }); + const summary = summaryRow( + host, + expanded, + tools, + input.previousSummaryRowsByHostId.get(host.id), + ); + summaryRowsByHostId.set(host.id, summary); const revealed = expanded ? folded.flatMap((item) => item.kind === "tool_call" || item.kind === "thought" @@ -173,6 +182,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { else hostIdByHiddenRowId.set(item.id, host.id); } output.push( + summary, ...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))), ); output.push(...tail.slice(replyIndex, runEnd)); @@ -190,26 +200,20 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { } flushRun(tail.length); - const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId); - if (turnsByHostId.size === 0) { + if (summaryRowsByHostId.size === 0) { return { tail, - turnsByHostId: EMPTY_TURNS, nestedItemIds: EMPTY_IDS, hostIdByFoldedMessageId: EMPTY_HOSTS, hostIdByHiddenRowId: EMPTY_HOSTS, - updatedHostIds, + summaryRowsByHostId: EMPTY_ROWS, }; } return { - tail: - output.length === tail.length && output.every((item, index) => item === tail[index]) - ? tail - : output, - turnsByHostId, + tail: output, nestedItemIds, hostIdByFoldedMessageId, hostIdByHiddenRowId, - updatedHostIds, + summaryRowsByHostId, }; } diff --git a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx index 22660b4606..289a6f748f 100644 --- a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx +++ b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx @@ -4,25 +4,25 @@ import { Summary } from "lucide-react-native"; import { useDisclosureHold } from "@/agent-stream/disclosure"; import { ExpandableBadge } from "@/components/message"; import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label"; -import type { CollapsedTurn } from "./model"; +import type { TurnSummaryItem } from "@/types/stream"; /** * The row a collapsed turn leaves under its user message, styled like a tool-call group. * Pressing it opens the turn's rows beneath it. */ export const TurnSummaryLine = memo(function TurnSummaryLine({ - turn, + item, onExpandedChange, }: { - turn: CollapsedTurn; + item: TurnSummaryItem; onExpandedChange: (hostId: string, expanded: boolean) => void; }) { const { t } = useTranslation(); - const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details"); + const label = useOverviewSummaryLabel(item.tools) || t("turnSummary.details"); const hold = useDisclosureHold(); const toggle = useCallback( - () => hold(() => onExpandedChange(turn.hostId, !turn.expanded)), - [hold, onExpandedChange, turn.expanded, turn.hostId], + () => hold(() => onExpandedChange(item.hostId, !item.expanded)), + [hold, onExpandedChange, item.expanded, item.hostId], ); return ( @@ -30,7 +30,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({ testID="turn-summary" label={label} icon={Summary} - isExpanded={turn.expanded} + isExpanded={item.expanded} onToggle={toggle} borderlessWhenExpanded /> diff --git a/packages/app/src/agent-stream/turn-footer.browser.test.tsx b/packages/app/src/agent-stream/turn-footer.browser.test.tsx new file mode 100644 index 0000000000..dec9b7fe15 --- /dev/null +++ b/packages/app/src/agent-stream/turn-footer.browser.test.tsx @@ -0,0 +1,94 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import type { TurnTiming } from "@/timeline/turn-time"; +import type { StreamItem } from "@/types/stream"; +import { createNativeStreamStrategy } from "./strategy-native"; +import { CompletedTurnFooterRow } from "./turn-footer"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +// A Sunday, so a turn that ended on Friday shows a weekday timestamp. +const NOW = new Date(2026, 10, 8, 5, 0); +const FRIDAY_NIGHT = new Date(2026, 10, 6, 23, 11); + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("React", React); + vi.useFakeTimers({ toFake: ["Date"], now: NOW }); + host = document.createElement("div"); + host.style.width = "880px"; + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(async () => { + act(() => root.unmount()); + host.remove(); + vi.useRealTimers(); + await i18n.changeLanguage("en"); + vi.unstubAllGlobals(); +}); + +const reply: StreamItem = { + kind: "assistant_message", + id: "reply", + text: "Done.", + timestamp: FRIDAY_NIGHT, +}; + +function renderCompletedFooter(timing: TurnTiming): void { + act(() => + root.render( + , + ), + ); +} + +/** How many painted lines each footer label takes. */ +function paintedLinesByLabel(element: HTMLElement): Record { + const lines: Record = {}; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const text = walker.currentNode.textContent ?? ""; + if (!text.trim()) continue; + const range = document.createRange(); + range.selectNodeContents(walker.currentNode); + lines[text] = new Set(Array.from(range.getClientRects(), (rect) => Math.round(rect.top))).size; + } + return lines; +} + +// The footer shows the duration and swaps to the end timestamp on hover or tap. Neither label +// may be sized by the other: a timestamp with more characters can still paint narrower. +it.each([ + { + language: "en", + timing: { completedAt: FRIDAY_NIGHT, durationMs: 26_000 }, + duration: "Worked for 26s", + }, + // CJK glyphs make the shorter string paint wider in every font. + { + language: "ja", + timing: { completedAt: FRIDAY_NIGHT, durationMs: 59 * 60_000 + 58_000 }, + duration: "作業時間 59m 58s", + }, +])("keeps both timing labels on one line when the chat has room ($language)", async (input) => { + await i18n.changeLanguage(input.language); + + renderCompletedFooter(input.timing); + + expect(paintedLinesByLabel(host)).toEqual({ + [input.duration]: 1, + "Friday 11:11 PM": 1, + }); +}); diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 08fceb4005..6699fb800b 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({ turnFooterSlot: { flexDirection: "row", alignItems: "center", - alignSelf: "flex-start", - height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING, + alignSelf: "stretch", + minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING, paddingBottom: TURN_FOOTER_BOTTOM_SPACING, }, turnFooterContent: { diff --git a/packages/app/src/agent-stream/use-history-start-pagination.ts b/packages/app/src/agent-stream/use-history-start-pagination.ts new file mode 100644 index 0000000000..1fb6168857 --- /dev/null +++ b/packages/app/src/agent-stream/use-history-start-pagination.ts @@ -0,0 +1,33 @@ +import { useRef, useState } from "react"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { + createHistoryStartPagination, + type HistoryStartPaginationInput, + type HistoryStartPaginationState, +} from "./history-start-pagination"; + +export function useHistoryStartPagination(input: { + initiallyVisible: boolean; + read: () => HistoryStartPaginationInput | null; + load: () => Promise; + scheduleSettle: () => void; +}) { + const read = useStableEvent(input.read); + const load = useStableEvent(input.load); + const scheduleSettle = useStableEvent(input.scheduleSettle); + const [state, setState] = useState(() => ({ + status: "dormant", + visible: input.initiallyVisible, + })); + const controller = useRef | null>(null); + if (!controller.current) { + controller.current = createHistoryStartPagination({ + initiallyVisible: input.initiallyVisible, + read, + load, + scheduleSettle, + changed: setState, + }); + } + return { state, controller: controller.current }; +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index d761b445bb..761e73bac6 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -248,6 +248,8 @@ export interface AgentStreamViewProps { turnPresentation: TurnPresentation; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; + isInitialHistoryLoading?: boolean; + revealInitialHistory?: boolean; /** Tail space required by a transparent overlay rendered at the bottom edge. */ bottomOverlayTailClearance?: number; /** Bottom offset required for controls floating above that overlay. */ @@ -345,6 +347,8 @@ const AgentStreamViewComponent = forwardRef - presentation.turns.turnsByHostId.get(hostId), - ); + // Read through a stable event so a collapse rerun does not change the renderer identity; + // nested rows are revised through `historyRowRevision`. const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => { const { nestedItemIds } = presentation.turns; - if (nestedItemIds.has(layoutItem.item.id)) { + const { item } = layoutItem; + if (nestedItemIds.has(item.id)) { const below = layoutItem.belowItem; return below && nestedItemIds.has(below.id) ? "nested" : "lastNested"; } - return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none"; + return item.kind === "turn_summary" && item.expanded ? "openedHost" : "none"; }); const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded; const renderUserMessageItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { - const collapsedTurn = getCollapsedTurn(item.id); - const message = ( + return ( ); - if (!collapsedTurn) { - return message; - } - return ( - <> - {message} - - - ); }, - [ - context.capabilities, - agentId, - client, - getCollapsedTurn, - pendingClientMessageIds, - resolvedServerId, - setTurnExpanded, - ], + [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -931,17 +915,10 @@ const AgentStreamViewComponent = forwardRef - {renderToolCallItem(layoutItem, item)} - {getCollapsedTurn(item.id) ? ( - - ) : null} - - ); + return renderToolCallItem(layoutItem, item); + + case "turn_summary": + return ; case "notification": return ; @@ -972,7 +949,6 @@ const AgentStreamViewComponent = forwardRef ({ contentById: { - has: (id: string) => - presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id), + has: (id: string) => presentation.historyGroupUpdatesByHostId.has(id), }, displayStateById: { // An opened lone call also marks its future group open; that row stays unrevised // until a group actually forms there. has: (id: string) => (expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) || - expandedTurnHostIds.has(id) || nestedItemIds.has(id), }, globalDisplayState: isMobile, @@ -1171,11 +1145,9 @@ const AgentStreamViewComponent = forwardRef ({ container: { + flex: 1, + minWidth: 0, + minHeight: 24, + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + }, + actions: { + height: 24, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], @@ -568,21 +577,25 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ marginTop: 0, marginLeft: -theme.spacing[1], }, - labelWrapper: { - position: "relative", + labelButton: { + flex: 1, + minHeight: 24, + flexDirection: "row", + alignItems: "center", + minWidth: 0, }, - labelSizer: { + label: { + width: "100%", + flexShrink: 0, color: theme.colors.foregroundMuted, fontSize: STREAM_METADATA_FONT_SIZE, + }, + overlappingLabel: { + marginLeft: "-100%", + }, + hiddenLabel: { opacity: 0, }, - labelOverlay: { - position: "absolute", - top: 0, - left: 0, - color: theme.colors.foregroundMuted, - fontSize: STREAM_METADATA_FONT_SIZE, - }, })); const TIMESTAMP_REVEAL_MS = 3000; @@ -649,31 +662,51 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ return ( - {getContent ? ( - + {getContent || canFork ? ( + + {getContent ? ( + + ) : null} + {canFork ? : null} + ) : null} - {canFork ? : null} {primaryLabel ? ( - - {/* Sizer reserves space for whichever label is longer so the - container width is stable across hover transitions. */} - - {primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel} + {/* Both labels participate in layout at the available width. Overlapping their + columns reserves the taller wrapped height without measuring text or characters. */} + + {primaryLabel} + + {canSwap ? ( + + {timestampLabel} - - {showTimestamp ? timestampLabel : primaryLabel} - - + ) : null} ) : null} diff --git a/packages/app/src/components/plan-card.tsx b/packages/app/src/components/plan-card.tsx index c0456f524d..65aaa96e92 100644 --- a/packages/app/src/components/plan-card.tsx +++ b/packages/app/src/components/plan-card.tsx @@ -1,3 +1,4 @@ +import { useDisclosureHold } from "@/agent-stream/disclosure"; import { MarkdownRenderer } from "@/components/markdown/renderer"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; @@ -50,7 +51,8 @@ function PlanCardContent({ () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), [expanded], ); - const toggleExpanded = useCallback(() => setExpanded((value) => !value), []); + const hold = useDisclosureHold(); + const toggleExpanded = useCallback(() => hold(() => setExpanded((value) => !value)), [hold]); const containerStyle = useMemo( () => [styles.container, disableOuterSpacing && styles.containerCompact], [disableOuterSpacing], diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 63a3144dda..36f3af0b28 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,6 +1,6 @@ import { AdaptiveWorkspaceChrome, - type WorkspaceCompactHeaderActions, + type WorkspaceCompactHeaderTabs, } from "@/screens/workspace/adaptive-workspace-chrome"; import { useWorkspaceTabDrag } from "./workspace-tab-drag-context"; import { @@ -66,12 +66,13 @@ import { isNative } from "@/constants/platform"; interface SplitContainerProps { layout: WorkspaceLayout; + /** The full workspace header: title, project row and workspace actions. */ renderMainHeader?: () => ReactNode; - renderCompactHeader: ( - tabs: WorkspaceTabDescriptor[], - activeTabKey: string, - actions: WorkspaceCompactHeaderActions, - ) => ReactNode; + /** + * The compact workspace header. With one presented pane it carries that pane's tab switcher in + * place of its tab rail; above a split it carries workspace controls only. + */ + renderCompactHeader: (paneTabs: WorkspaceCompactHeaderTabs | null) => ReactNode; workspaceKey: string; normalizedServerId: string; normalizedWorkspaceId: string; @@ -128,6 +129,11 @@ interface SplitNodeViewProps extends Omit< windowChromeCorners: WindowChromeCorners; maximizedPaneId: string | null; workspaceHasMultiplePanes: boolean; + /** + * One presented pane draws the workspace header itself and may merge its tabs into the compact + * header. Split panes share one header above them and always show their tab rails. + */ + ownsWorkspaceHeader: boolean; onTogglePaneMaximized: (paneId: string) => void; } @@ -244,11 +250,27 @@ export function SplitContainer({ : inheritedWindowChromeCorners; const mainColumnStyle = styles.mainColumn; const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]); + const panesShareWorkspaceHeader = hasMultipleVisiblePanes(splitRoot.root); + const sharedCompactHeader = useMemo( + () => + panesShareWorkspaceHeader ? ( + + + {renderCompactHeader(null)} + + ) : null, + [panesShareWorkspaceHeader, renderCompactHeader], + ); return ( + {panesShareWorkspaceHeader ? ( + + {renderMainHeader?.()} + + ) : null} {splitRoot.usesFallbackStrip && } {renderRoot ? ( @@ -578,6 +607,7 @@ function SplitNodeView({ onTogglePaneMaximized={onTogglePaneMaximized} focusModeEnabled={focusModeEnabled} onExitFocusMode={onExitFocusMode} + ownsWorkspaceHeader={ownsWorkspaceHeader} renderMainHeader={renderMainHeader} renderCompactHeader={renderCompactHeader} /> @@ -638,6 +668,7 @@ const SplitPaneView = memo(function SplitPaneView({ onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, + ownsWorkspaceHeader, renderMainHeader, renderCompactHeader, }: SplitPaneViewProps) { @@ -770,13 +801,24 @@ const SplitPaneView = memo(function SplitPaneView({ createNewTab, }); const compactHeader = useMemo( - () => ( - - - {renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)} - - ), - [renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions], + () => + ownsWorkspaceHeader ? ( + + + {renderCompactHeader({ + tabs: paneTabs, + activeTabKey: activeTabDescriptor?.key ?? "", + actions: compactHeaderActions, + })} + + ) : null, + [ + ownsWorkspaceHeader, + renderCompactHeader, + paneTabs, + activeTabDescriptor?.key, + compactHeaderActions, + ], ); return ( @@ -787,7 +829,7 @@ const SplitPaneView = memo(function SplitPaneView({ testID={`workspace-pane-${pane.id}`} > - {renderMainHeader?.()} + {ownsWorkspaceHeader ? renderMainHeader?.() : null} diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx index 164120c491..1ca418a541 100644 --- a/packages/app/src/components/ui/hover-card.tsx +++ b/packages/app/src/components/ui/hover-card.tsx @@ -4,6 +4,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -17,23 +18,17 @@ import { StyleSheet } from "react-native-unistyles"; import { Portal } from "@gorhom/portal"; import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root"; import { FloatingSurface } from "@/components/ui/floating"; -import { - measureFloatingPanelPortalHost, - useFloatingPanelPortalHostName, -} from "@/components/ui/floating-panel-portal"; +import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal"; import { isWeb } from "@/constants/platform"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; -import { - computePosition, - measureElement, - type Alignment, - type Placement, - type Rect, - type Size, -} from "./anchor"; +import { computePosition, type Alignment, type Placement, type Rect } from "./anchor"; const CLOSE_GRACE_MS = 100; +// One card per layer: moving onto a sibling trigger replaces the open card instead of stacking +// a second one beside it for the grace period. Nested cards open on a higher layer. +const openCardCloseByLayer = new Map void>(); + interface HoverCardContextValue { open: boolean; layer: number; @@ -116,12 +111,26 @@ function WebHoverCard({ }, CLOSE_GRACE_MS); }, [keyboardFocusInside]); + const closeNow = useCallback(() => { + clearGraceTimer(); + setOpen(false); + }, [clearGraceTimer]); + const openNow = useCallback(() => { clearGraceTimer(); - if (!disabled) { - setOpen(true); - } - }, [clearGraceTimer, disabled]); + if (disabled) return; + const openSibling = openCardCloseByLayer.get(layer); + if (openSibling && openSibling !== closeNow) openSibling(); + openCardCloseByLayer.set(layer, closeNow); + setOpen(true); + }, [clearGraceTimer, closeNow, disabled, layer]); + + useEffect(() => { + if (!open) return; + return () => { + if (openCardCloseByLayer.get(layer) === closeNow) openCardCloseByLayer.delete(layer); + }; + }, [closeNow, layer, open]); const keyPressed = useCallback( (event: KeyboardInputEvent): boolean => { @@ -299,17 +308,29 @@ interface HoverCardContentProps { export function HoverCardContent(props: HoverCardContentProps): ReactElement | null { const ctx = useContext(HoverCardContext); + const portalHostName = useFloatingPanelPortalHostName(); if (!ctx?.open) return null; return ( - + + + ); } +function readDomRect(view: View | null): Rect | null { + const element = view as unknown as HTMLElement | null; + if (!element) return null; + const { x, y, width, height } = element.getBoundingClientRect(); + return { x, y, width, height }; +} + +// Rendered inside the portal so the card is placed in the commit that mounts it: the first +// painted frame already sits beside the trigger. Web-only, like the rest of the open card. function HoverCardSurface({ layer, triggerRef, @@ -327,48 +348,29 @@ function HoverCardSurface({ triggerRef: RefObject; contentRef: RefObject; }): ReactElement { - const portalHostName = useFloatingPanelPortalHostName(); - const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null); - const [contentSize, setContentSize] = useState(null); + const overlayRef = useRef(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - useEffect(() => { - if (!triggerRef.current) return; - let cancelled = false; - void Promise.all([ - measureElement(triggerRef.current), - measureFloatingPanelPortalHost(portalHostName), - ]).then(([triggerRect, hostRect]) => { - if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect }); - return undefined; - }); - return () => { - cancelled = true; - }; - }, [portalHostName, triggerRef]); - - useEffect(() => { - if (!anchor || !contentSize) return; + const place = useCallback(() => { + const triggerRect = readDomRect(triggerRef.current); + const overlayRect = readDomRect(overlayRef.current); + const content = contentRef.current as unknown as HTMLElement | null; + if (!triggerRect || !overlayRect || !content) return; const { width, height } = Dimensions.get("window"); const { x, y } = computePosition({ - triggerRect: anchor.triggerRect, - contentSize, + triggerRect, + contentSize: { width: content.offsetWidth, height: content.offsetHeight }, displayArea: { x: 0, y: 0, width, height }, placement, flipHorizontal: true, alignment, offset, }); - setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y }); - }, [alignment, anchor, contentSize, offset, placement]); + const next = { x: x - overlayRect.x, y: y - overlayRect.y }; + setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next)); + }, [alignment, contentRef, offset, placement, triggerRef]); - const handleLayout = useCallback( - (event: { nativeEvent: { layout: { width: number; height: number } } }) => { - const { width, height } = event.nativeEvent.layout; - setContentSize({ width, height }); - }, - [], - ); + useLayoutEffect(place, [place]); const frameStyle = useMemo( () => ({ @@ -381,24 +383,26 @@ function HoverCardSurface({ const surfaceStyle = useMemo(() => [styles.surface, style], [style]); return ( - - - - {children} - - - + + + {children} + + ); } diff --git a/packages/app/src/components/user-message/index.tsx b/packages/app/src/components/user-message/index.tsx index e7bef2d177..dc73c517ca 100644 --- a/packages/app/src/components/user-message/index.tsx +++ b/packages/app/src/components/user-message/index.tsx @@ -9,8 +9,11 @@ import { useProviderIcon } from "@/components/provider-icons"; import { useSessionStore } from "@/stores/session-store"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { isNative } from "@/constants/platform"; +import { useDisclosureHold } from "@/agent-stream/disclosure"; const PREVIEW_HEIGHT = 240; +// Show more and show less sit under the text they open and close. +const TRIGGER_BELOW_CONTENT = { triggerEdge: "bottom" } as const; // Native View still clips with "scroll", but Yoga measures its children without // the height cap. "hidden" caps the measured body too, hiding the expand control. const previewStyle: ViewStyle = { @@ -26,7 +29,11 @@ export function MessagePreview({ children }: { children: ReactNode }) { (event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT), [], ); - const toggle = useCallback(() => setExpanded((value) => !value), []); + const hold = useDisclosureHold(); + const toggle = useCallback( + () => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT), + [hold], + ); const accessibilityState = useMemo(() => ({ expanded }), [expanded]); return ( <> diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index fc82a73e6d..5cb380c34b 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1791,6 +1791,9 @@ export const MessageInput = forwardRef( handleSendMessage, handleQueueMessage, }); + if (hasFinePointer()) { + textInputRef.current?.focus(); + } }, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]); const handleAlternateSendAction = useCallback(() => { diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index d6d9011259..3590cfe30c 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -1,4 +1,4 @@ -import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; +import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -144,10 +144,21 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} ["status", "providers_snapshot_update"], () => {}, ), - observeCheckoutDiff(cwd, compare) { + observeDiff(input) { + const cwd = input.cwd; + let mode: "uncommitted" | "base" | "all" = "uncommitted"; + if (input.head.kind === "rev") mode = "base"; + else if (input.mergeBase) mode = "all"; + const compare = { + mode, + ...(input.base.kind === "rev" && input.base.rev !== "HEAD" + ? { baseRef: input.base.rev } + : {}), + ignoreWhitespace: input.ignoreWhitespace, + }; const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`; subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId }); - const handle = observe( + const handle = observe( { subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" }, ["checkout_diff_update", "subscribe_checkout_diff_response"], () => unsubscribeCheckoutDiffCalls.push(subscriptionId), @@ -155,9 +166,43 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} "subscriptionId" in message.payload && message.payload.subscriptionId === subscriptionId, ); - if (!config.rejectCheckoutDiffSubscribe) return handle; - return { + const modern: OwnedSubscription = { ...handle, + ready: Promise.resolve({ + phase: "ready", + revision: 1, + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + files: [], + diffTooLarge: false, + subscriptionId, + }), + subscribe: (observer) => + handle.subscribe({ + snapshot: (payload) => + observer.snapshot({ + phase: "ready", + revision: 1, + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + files: payload.files, + diffTooLarge: payload.diffTooLarge === true, + subscriptionId, + }), + update: (message) => { + if (message.type === "checkout_diff_update") + observer.snapshot({ + phase: "ready", + revision: 2, + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + files: message.payload.files, + diffTooLarge: message.payload.diffTooLarge === true, + subscriptionId, + }); + }, + }), + }; + if (!config.rejectCheckoutDiffSubscribe) return modern; + return { + ...modern, subscribe: (observer) => { queueMicrotask(() => observer.error?.(new Error("subscribe failed"))); return () => {}; @@ -328,7 +373,7 @@ describe("server data push router", () => { const first = publish(files, "first"); const same = publish(structuredClone(files), "reopen"); expect(same.files).toBe(first.files); - expect(same.requestId).toBe("reopen"); + expect(same.requestId).toBe("diff:1:ready"); const changed = structuredClone(files); changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable"; const next = publish(changed, "edit"); @@ -430,7 +475,7 @@ describe("server data push router", () => { files: [], error: null, diffTooLarge: true, - requestId: "diff-1", + requestId: "diff:1:ready", }); fake.emit({ @@ -449,7 +494,7 @@ describe("server data push router", () => { files: [], error: null, diffTooLarge: true, - requestId: `subscription:${serverSubscriptionId}`, + requestId: "diff:2:ready", }); unsubscribeObserver(); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index cc32f8f8e3..5e51ded0d4 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,3 +1,5 @@ +import { checkoutDiffSnapshot, type CheckoutDiffSnapshot } from "@/git/diff-snapshot"; +import type { DiffState } from "@getpaseo/client"; import type { OwnedSubscription } from "@getpaseo/client"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; @@ -23,14 +25,9 @@ type ProvidersSnapshotUpdateMessage = Extract< SessionOutboundMessage, { type: "providers_snapshot_update" } >; -type SubscribeCheckoutDiffResponseMessage = Extract< - SessionOutboundMessage, - { type: "subscribe_checkout_diff_response" } ->; type StatusMessage = Extract; type TerminalsChangedMessage = Extract; -type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"]; -type CheckoutDiffCachePayload = Omit; +type CheckoutDiffCachePayload = CheckoutDiffSnapshot; type ListTerminalsPayload = ListTerminalsResponse["payload"]; interface CheckoutDiffCompare { @@ -49,7 +46,7 @@ interface CheckoutDiffRoute { } interface CheckoutDiffRegistration extends CheckoutDiffRoute { - subscription: OwnedSubscription; + subscription: OwnedSubscription; } interface WorkspaceTerminalsRoute { @@ -75,7 +72,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage; interface ServerDataPushClient { getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"]; observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"]; - observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"]; + observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"]; observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"]; } @@ -339,34 +336,34 @@ function reconcileCheckoutDiffSubscriptions(input: { } for (const [key, desired] of input.desired) { if (input.active.has(key)) continue; - const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare); + const { mode, baseRef, ignoreWhitespace } = desired.compare; + const revision = mode === "uncommitted" ? "HEAD" : baseRef; + if (!revision) continue; + const subscription = input.client.observeDiff({ + cwd: desired.cwd, + base: { kind: "rev", rev: revision }, + head: mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" }, + mergeBase: mode !== "uncommitted", + ignoreWhitespace, + }); const registration = { ...desired, subscription }; input.active.set(key, registration); - const apply = ( - payload: Omit & { requestId?: string }, - ) => { - if (input.active.get(key) !== registration) return; - const { subscriptionId, ...snapshot } = payload; - setCheckoutDiffPayload({ - activeCheckoutDiffSubscriptions: input.active, - queryClient: input.queryClient, - serverId: input.serverId, - subscriptionId: key, - payload: { - ...snapshot, - files: orderCheckoutDiffFiles(payload.files), - requestId: payload.requestId ?? `subscription:${subscriptionId}`, - }, - }); - }; subscription.subscribe({ - snapshot: apply, - update: (message) => { - if (message.type === "checkout_diff_update") apply(message.payload); + snapshot: (state) => { + if (input.active.get(key) !== registration) return; + const snapshot = checkoutDiffSnapshot(desired.cwd, state); + setCheckoutDiffPayload({ + activeCheckoutDiffSubscriptions: input.active, + queryClient: input.queryClient, + serverId: input.serverId, + subscriptionId: key, + payload: { ...snapshot, files: orderCheckoutDiffFiles(snapshot.files) }, + }); }, + update: () => {}, error: (error) => { if (input.active.get(key) === registration) input.active.delete(key); - console.error("[server-data] observeCheckoutDiff failed", { + console.error("[server-data] observeDiff failed", { serverId: input.serverId, cwd: desired.cwd, error, diff --git a/packages/app/src/diagnostics/app-diagnostic-report.test.ts b/packages/app/src/diagnostics/app-diagnostic-report.test.ts index df10b01fed..b330d56745 100644 --- a/packages/app/src/diagnostics/app-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/app-diagnostic-report.test.ts @@ -46,6 +46,31 @@ function makeHost(): HostProfile { }; } +function makeSnapshot(host: HostProfile): HostRuntimeSnapshot { + return { + serverId: host.serverId, + activeConnectionId: "relay:relay.secret.test:443", + activeConnection: { + type: "relay", + endpoint: "relay.secret.test:443", + display: "relay", + }, + connectionStatus: "online", + client: null, + lastError: null, + lastOnlineAt: "2026-06-25T00:00:00.000Z", + agentDirectoryStatus: "ready", + agentDirectoryError: null, + hasEverLoadedAgentDirectory: true, + probeByConnectionId: new Map([ + ["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }], + ["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }], + ]), + clientGeneration: 1, + connectionEpoch: 1, + }; +} + describe("app diagnostics report", () => { test("reports whether the connected daemon is managed by Paseo Desktop", () => { const report = formatServerInfoSection({ @@ -61,31 +86,11 @@ describe("app diagnostics report", () => { test("formats connection rows without raw connection details", () => { const host = makeHost(); - const snapshot: HostRuntimeSnapshot = { - serverId: host.serverId, - activeConnectionId: "relay:relay.secret.test:443", - activeConnection: { - type: "relay", - endpoint: "relay.secret.test:443", - display: "relay", - }, - connectionStatus: "online", - client: null, - lastError: null, - lastOnlineAt: "2026-06-25T00:00:00.000Z", - agentDirectoryStatus: "ready", - agentDirectoryError: null, - hasEverLoadedAgentDirectory: true, - probeByConnectionId: new Map([ - ["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }], - ["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }], - ]), - clientGeneration: 1, - connectionEpoch: 1, - }; + const snapshot = makeSnapshot(host); const report = formatHostRuntimeSection({ host, snapshot }); + expect(report).toContain("Last online: 2026-06-25T00:00:00.000Z"); expect(report).toContain("direct TCP"); expect(report).toContain("relay"); expect(report).toContain("local socket"); @@ -97,6 +102,58 @@ describe("app diagnostics report", () => { expect(report).not.toContain("tcp-password"); }); + test.each(["connecting", "offline", "error"] as const)( + "does not claim a previously connected host was never online while %s", + (connectionStatus) => { + const host = makeHost(); + const snapshot = makeSnapshot(host); + const report = formatHostRuntimeSection({ + host, + snapshot: { ...snapshot, connectionStatus, lastOnlineAt: null }, + }); + + expect(report).toContain("Last online: unknown"); + expect(report).not.toContain("Last online: never"); + }, + ); + + test("does not infer connection history when the runtime has not started", () => { + const report = formatHostRuntimeSection({ host: makeHost(), snapshot: null }); + + expect(report).toContain("Last online: unknown"); + }); + + test.each(["online", "error"] as const)( + "identifies a routed connection while %s", + (connectionStatus) => { + const host = makeHost(); + host.connections.push({ id: "routed-origin", type: "routed", originServerId: "srv-origin" }); + const snapshot: HostRuntimeSnapshot = { + serverId: host.serverId, + activeConnectionId: "routed-origin", + activeConnection: { type: "routed", endpoint: "srv-origin", display: "via srv-origin" }, + connectionStatus, + client: null, + lastError: connectionStatus === "error" ? "Host disconnected" : null, + lastOnlineAt: null, + agentDirectoryStatus: "ready", + agentDirectoryError: null, + hasEverLoadedAgentDirectory: true, + probeByConnectionId: new Map(), + clientGeneration: 1, + connectionEpoch: 1, + }; + + const report = formatHostRuntimeSection({ host, snapshot }); + + expect(report).toContain("Active connection: routed"); + expect(report).toContain("Connection 5: routed, active, probe=unknown"); + expect(report).toContain(`Status: ${connectionStatus}`); + expect(report).toContain(`Last error: ${snapshot.lastError ?? "none"}`); + expect(report).not.toContain("srv-origin"); + }, + ); + test("redacts saved connection secrets from collected daemon and desktop text", () => { const host = makeHost(); const redacted = redactAppDiagnosticReport( diff --git a/packages/app/src/diagnostics/app-diagnostic-report.ts b/packages/app/src/diagnostics/app-diagnostic-report.ts index afb38e7aad..3ba48fc1f0 100644 --- a/packages/app/src/diagnostics/app-diagnostic-report.ts +++ b/packages/app/src/diagnostics/app-diagnostic-report.ts @@ -45,7 +45,7 @@ export function formatHostRuntimeSection(input: { ? describeConnectionKind(snapshot.activeConnection.type) : "none", }, - { label: "Last online", value: snapshot?.lastOnlineAt ?? "never" }, + { label: "Last online", value: snapshot?.lastOnlineAt ?? "unknown" }, { label: "Last error", value: snapshot?.lastError ?? "none" }, { label: "Agent directory", @@ -100,6 +100,8 @@ export function describeConnectionKind(type: HostConnection["type"] | string): s return "local pipe"; case "relay": return "relay"; + case "routed": + return "routed"; default: return "unknown"; } diff --git a/packages/app/src/git/changed-files-tree.tsx b/packages/app/src/git/changed-files-tree.tsx index 0b88d33e96..f020cfa101 100644 --- a/packages/app/src/git/changed-files-tree.tsx +++ b/packages/app/src/git/changed-files-tree.tsx @@ -13,10 +13,10 @@ import { type DiffTreeRow, } from "@/git/diff-tree"; import { FileHeader } from "@/git/file-header"; -import type { ParsedDiffFile } from "@/git/use-diff-query"; +import type { DiffFileHeader } from "@/git/diff-snapshot"; export interface ChangedFilesTreeProps { - files: ParsedDiffFile[]; + files: DiffFileHeader[]; mode: WorkingDiffMode; onSelectFile: (path: string) => void; collapsedFolderPaths: string[]; diff --git a/packages/app/src/git/diff-order.ts b/packages/app/src/git/diff-order.ts index d57bb0ef29..0ad572866f 100644 --- a/packages/app/src/git/diff-order.ts +++ b/packages/app/src/git/diff-order.ts @@ -13,6 +13,6 @@ export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[ // chains, so it is skipped; the collapsed set is empty because the flat list // always carries every file, whatever the rail has collapsed. return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) => - row.kind === "file" ? [row.file] : [], + row.kind === "file" ? [files[row.fileIndex]] : [], ); } diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 59d3b49797..3581166980 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1,3 +1,4 @@ +import type { DiffFileHeader } from "@/git/diff-snapshot"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { floatingActionsClearance } from "@/components/ui/floating-action-button"; import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react"; @@ -1094,6 +1095,7 @@ interface DiffBodyContentProps { emptyMessage: string; emptyAction: ChangesEmptyAction | null; children: ReactElement; + loadingLabel: string; checkingRepositoryLabel: string; notRepositoryLabel: string; } @@ -1110,11 +1112,16 @@ function DiffBodyContent({ emptyAction, children, checkingRepositoryLabel, + loadingLabel, notRepositoryLabel, }: DiffBodyContentProps) { if (isStatusLoading) { return ( - + {checkingRepositoryLabel} @@ -1136,12 +1143,16 @@ function DiffBodyContent({ } if (isDiffLoading) { return ( - + ); } - if (diffTooLarge) { + if (diffTooLarge && !hasChanges) { return ; } if (diffErrorMessage) { @@ -1320,7 +1331,7 @@ function ChangesTreeRail({ }: { shown: boolean; children: ReactElement; - files: ParsedDiffFile[]; + files: DiffFileHeader[]; mode: WorkingDiffMode; onSelectFile: (path: string) => void; treeWidth?: number; @@ -1360,7 +1371,7 @@ function ChangesBody({ children: ReactElement; desktopTreeVisible: boolean; isMobile: boolean; - files: ParsedDiffFile[]; + files: DiffFileHeader[]; mode: WorkingDiffMode; onSelectFile: (path: string) => void; treeWidth?: number; @@ -1585,6 +1596,7 @@ export function ChangesSurface({ selectBase: handleSelectBase, selectAll: handleSelectAll, files, + treeFiles, diffPayloadError, diffTooLarge, isDiffLoading, @@ -1834,6 +1846,7 @@ export function ChangesSurface({ hasChanges={hasChanges} emptyMessage={emptyMessage} emptyAction={emptyAction} + loadingLabel={t("common.loading")} checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")} notRepositoryLabel={t("workspace.git.diff.notRepository")} > @@ -1864,7 +1877,7 @@ export function ChangesSurface({ presentation={presentation} desktopTreeVisible={desktopTreeVisible} isMobile={isMobile} - files={files} + files={treeFiles} mode={workingMode} onSelectFile={handleSelectTreeFile} treeWidth={instanceState.treeWidth} diff --git a/packages/app/src/git/diff-sharing.ts b/packages/app/src/git/diff-sharing.ts index 6bdac10ddf..d426960d9f 100644 --- a/packages/app/src/git/diff-sharing.ts +++ b/packages/app/src/git/diff-sharing.ts @@ -1,7 +1,8 @@ import { replaceEqualDeep } from "@tanstack/react-query"; -import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; +import type { CheckoutDiffSnapshot } from "./diff-snapshot"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; -type DiffPayload = Omit; +type DiffPayload = CheckoutDiffSnapshot; /** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */ export function retainDiffFiles( @@ -30,6 +31,7 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay error === old.error && old.cwd === next.cwd && old.requestId === next.requestId && + old.entries === next.entries && old.diffTooLarge === next.diffTooLarge ? old : { ...next, files, error }; diff --git a/packages/app/src/git/diff-snapshot.ts b/packages/app/src/git/diff-snapshot.ts new file mode 100644 index 0000000000..84f650b588 --- /dev/null +++ b/packages/app/src/git/diff-snapshot.ts @@ -0,0 +1,74 @@ +import type { DiffState, DiffEntry } from "@getpaseo/client"; +import type { + DiffError, + ParsedDiffFile, + SubscribeCheckoutDiffRequest, +} from "@getpaseo/protocol/messages"; +export type DiffFileHeader = Omit; +export interface CheckoutDiffSnapshot { + requestId: string; + cwd: string; + files: ParsedDiffFile[]; + entries?: DiffEntry[]; + error: DiffError | null; + diffTooLarge?: boolean; +} +export function checkoutDiffSnapshot(cwd: string, state: DiffState): CheckoutDiffSnapshot { + const common = { cwd, requestId: `diff:${state.revision}:${state.phase}` }; + if (state.phase === "loading") + return { ...common, files: [], entries: state.entries, error: null }; + if (state.phase === "error") return { ...common, files: [], error: state.error }; + return { ...common, files: state.files, error: null, diffTooLarge: state.diffTooLarge }; +} +export function diffEntryHeaders(entries: DiffEntry[]): DiffFileHeader[] { + return entries.map((entry) => ({ + path: entry.path, + ...(entry.oldPath === null ? {} : { oldPath: entry.oldPath }), + isNew: entry.change === "added", + isDeleted: entry.change === "deleted", + additions: entry.additions, + deletions: entry.deletions, + status: entry.content === "binary" ? "binary" : "ok", + })); +} +export interface CheckoutDiffResult { + files: ParsedDiffFile[]; + entries: DiffEntry[] | null; + payloadError: CheckoutDiffSnapshot["error"]; + diffTooLarge: boolean; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + error: null; +} + +/** + * The result derives from the payload alone. Until a payload arrives there is no + * diff to describe, so the boundary reports loading rather than an empty diff — + * whether the query is idle (an inactive retained panel), in flight, or the host + * is disconnected. Reporting `files: []` as settled made callers render "+0 -0" + * and "No changes" for a diff nobody has fetched yet. + */ +export function deriveCheckoutDiffResult(payload: CheckoutDiffSnapshot | null): CheckoutDiffResult { + const payloadError = payload?.error ?? null; + return { + files: payload?.files ?? [], + entries: payload?.entries ?? null, + payloadError, + diffTooLarge: payload?.diffTooLarge === true, + isLoading: payload === null || payload.entries !== undefined, + isFetching: false, + isError: Boolean(payloadError), + error: null, + }; +} + +export function checkoutDiffComparisonError( + compare: Pick, +): DiffError | null { + if (compare.mode === "uncommitted" || compare.baseRef?.trim()) return null; + return { + code: "INVALID_COMPARE", + message: "Comparison base is unavailable. Select Uncommitted to view pending changes.", + }; +} diff --git a/packages/app/src/git/diff-tree.ts b/packages/app/src/git/diff-tree.ts index 49709cd5e2..c3130b1cae 100644 --- a/packages/app/src/git/diff-tree.ts +++ b/packages/app/src/git/diff-tree.ts @@ -1,6 +1,6 @@ -import type { ParsedDiffFile } from "@/git/use-diff-query"; +import type { DiffFileHeader } from "@/git/diff-snapshot"; -// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view +// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view // renders. The tree renders on every form factor, consistent with the Files // explorer. // @@ -17,7 +17,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query"; export interface DiffTreeFileNode { kind: "file"; - file: ParsedDiffFile; + file: DiffFileHeader; fileIndex: number; /** basename, e.g. "diff-pane.tsx" */ name: string; @@ -47,7 +47,7 @@ export interface DiffTreeFolderRow { export interface DiffTreeFileRow { kind: "file"; - file: ParsedDiffFile; + file: DiffFileHeader; fileIndex: number; depth: number; } @@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void { } /** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */ -export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode { +export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode { const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] }; const dirByPath = new Map([["", root]]); diff --git a/packages/app/src/git/file-header.tsx b/packages/app/src/git/file-header.tsx index d19d58cbf2..39427a612b 100644 --- a/packages/app/src/git/file-header.tsx +++ b/packages/app/src/git/file-header.tsx @@ -28,11 +28,11 @@ import { fileNameForPath, formatDiffCount, } from "@/git/file-header-presentation"; -import type { ParsedDiffFile } from "@/git/use-diff-query"; +import type { DiffFileHeader } from "@/git/diff-snapshot"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; export interface FileHeaderProps { - file: ParsedDiffFile; + file: DiffFileHeader; workspaceFileDragScope?: { serverId: string; workspaceId: string }; bodyVisible: boolean; showsBodyState?: boolean; @@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole return showsBodyState ? bodyVisible : undefined; } -function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) { +function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) { if (!canvasRendered) return undefined; return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`; } @@ -130,7 +130,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) { ]; } -function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" { +function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" { return diffFileChangeKind(file); } diff --git a/packages/app/src/git/use-diff-query.test.ts b/packages/app/src/git/use-diff-query.test.ts index 94b18a7d16..809319bca4 100644 --- a/packages/app/src/git/use-diff-query.test.ts +++ b/packages/app/src/git/use-diff-query.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; -import { deriveCheckoutDiffResult } from "./use-diff-query"; +import { deriveCheckoutDiffResult } from "./diff-snapshot"; function createParsedDiffFile(path: string): ParsedDiffFile { return { @@ -59,6 +59,7 @@ describe("deriveCheckoutDiffResult", () => { expect(result).toEqual({ files: [], + entries: null, payloadError: error, diffTooLarge: true, isLoading: false, @@ -68,3 +69,27 @@ describe("deriveCheckoutDiffResult", () => { }); }); }); + +it("shows the announced tree while file bodies are still loading", () => { + const entries = [ + { + path: "src/app.ts", + oldPath: null, + change: "modified", + content: "text", + additions: 3, + deletions: 1, + }, + ] as const; + const result = deriveCheckoutDiffResult({ ...createPayload([]), entries: [...entries] }); + expect(result.isLoading).toBe(true); + expect(result.entries).toEqual(entries); + expect(result.files).toEqual([]); +}); + +it("settles a comparison without a base and leaves Uncommitted available", async () => { + const { checkoutDiffComparisonError } = await import("./diff-snapshot"); + expect(checkoutDiffComparisonError({ mode: "all" })).toMatchObject({ code: "INVALID_COMPARE" }); + expect(checkoutDiffComparisonError({ mode: "base" })).toMatchObject({ code: "INVALID_COMPARE" }); + expect(checkoutDiffComparisonError({ mode: "uncommitted" })).toBeNull(); +}); diff --git a/packages/app/src/git/use-diff-query.ts b/packages/app/src/git/use-diff-query.ts index ebfcd4c354..6ba1202bb7 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -5,7 +5,13 @@ import { useRetainedPanelActive } from "@/components/retained-panel"; import { useReplicaQuery } from "@/data/query"; import { checkoutDiffPushRoute } from "@/data/push-router"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; +import { + checkoutDiffComparisonError, + deriveCheckoutDiffResult, + type CheckoutDiffSnapshot, +} from "./diff-snapshot"; +export { deriveCheckoutDiffResult } from "./diff-snapshot"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; import { checkoutDiffQueryKey } from "@/git/query-keys"; interface UseCheckoutDiffQueryOptions { @@ -18,7 +24,7 @@ interface UseCheckoutDiffQueryOptions { queryScope?: string; } -type CheckoutDiffQueryPayload = Omit; +type CheckoutDiffQueryPayload = CheckoutDiffSnapshot; // Re-export the canonical protocol type so all consumers share one definition. export type { ParsedDiffFile }; @@ -77,7 +83,11 @@ export function useCheckoutDiffQuery({ return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey; }, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); - const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate); + const comparisonError = + queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null; + const routeEnabled = Boolean( + queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError, + ); const query = useReplicaQuery({ queryKey, @@ -97,46 +107,13 @@ export function useCheckoutDiffQuery({ }), }); - return deriveCheckoutDiffResult( + const boundaryError = requiresUpdate && isConnected - ? { - requestId: "unsupported", - cwd, - files: [], - error: { code: "UNKNOWN", message: "Update the host to view All changes" }, - } + ? { code: "UNKNOWN" as const, message: "Update the host to view All changes" } + : comparisonError; + return deriveCheckoutDiffResult( + boundaryError + ? { requestId: "unavailable", cwd, files: [], error: boundaryError } : (query.data ?? null), ); } - -export interface CheckoutDiffResult { - files: ParsedDiffFile[]; - payloadError: CheckoutDiffQueryPayload["error"]; - diffTooLarge: boolean; - isLoading: boolean; - isFetching: boolean; - isError: boolean; - error: null; -} - -/** - * The result derives from the payload alone. Until a payload arrives there is no - * diff to describe, so the boundary reports loading rather than an empty diff — - * whether the query is idle (an inactive retained panel), in flight, or the host - * is disconnected. Reporting `files: []` as settled made callers render "+0 -0" - * and "No changes" for a diff nobody has fetched yet. - */ -export function deriveCheckoutDiffResult( - payload: CheckoutDiffQueryPayload | null, -): CheckoutDiffResult { - const payloadError = payload?.error ?? null; - return { - files: payload?.files ?? [], - payloadError, - diffTooLarge: payload?.diffTooLarge === true, - isLoading: payload === null, - isFetching: false, - isError: Boolean(payloadError), - error: null, - }; -} diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index f835d44f53..9f32ce8819 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -9,6 +9,8 @@ import { useReviewAttachmentSnapshot, useReviewFeedback, } from "@/review"; +import { useSessionStore } from "@/stores/session-store"; +import { diffEntryHeaders } from "./diff-snapshot"; import { useCheckoutDiffQuery } from "@/git/use-diff-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useWorkingDiffComparison } from "@/git/working-diff-comparison"; @@ -44,7 +46,11 @@ export function useWorkingDiff({ const statusErrorMessage = status?.error?.message ?? (isStatusError && statusError instanceof Error ? statusError.message : null); - const baseRef = gitStatus?.baseRef ?? undefined; + const modernDiff = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.diffSubscription === true, + ); + // COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06. + const baseRef = (modernDiff ? gitStatus?.comparisonBaseRef : gitStatus?.baseRef) ?? undefined; const currentBranchName = gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null; @@ -59,6 +65,7 @@ export function useWorkingDiff({ const { files, + entries, payloadError: diffPayloadError, diffTooLarge, isLoading: isDiffLoading, @@ -71,6 +78,7 @@ export function useWorkingDiff({ enabled: enabled && isGit, queryScope, }); + const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]); const reviewDraftKey = useMemo( () => buildReviewDraftKey({ @@ -112,6 +120,7 @@ export function useWorkingDiff({ selectBase, selectAll, files, + treeFiles, diffPayloadError, diffTooLarge, isDiffLoading, diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index 96b0395e76..efd17c74b3 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -4,10 +4,14 @@ import type { FetchAgentHistoryPageInfo, } from "@getpaseo/client/internal/daemon-client"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + useHostRuntimeConnectionStatuses, + useHosts, +} from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key"; @@ -222,32 +226,29 @@ export function useAgentHistory(options: { const { t } = useTranslation(); const daemons = useHosts(); const runtime = getHostRuntimeStore(); - const runtimeVersion = useSyncExternalStore( - (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => runtime.getVersion(), - () => runtime.getVersion(), - ); const serverId = useMemo(() => { const value = options.serverId; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; }, [options.serverId]); const enabled = options.enabled ?? true; + const serverIds = useMemo( + () => (serverId ? [serverId] : daemons.map((daemon) => daemon.serverId)), + [daemons, serverId], + ); + const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds); // A host the user asked about splits two ways: one this fetch can reach, and // one whose sessions will be missing from the answer. Both come out of here, // because dropping the unreachable ones silently is what lets the list — and // worse, "No sessions match" — overstate what was actually searched. const { targetHosts, unreachableHosts } = useMemo(() => { - void runtimeVersion; const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])); - const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId); const hosts: AgentHistoryHost[] = []; const unreachable: AgentHistoryHostError[] = []; for (const targetServerId of serverIds) { - const snapshot = runtime.getSnapshot(targetServerId); const client = runtime.getClient(targetServerId); const serverName = serverLabelById.get(targetServerId) ?? targetServerId; - if (!client || !isHostRuntimeConnected(snapshot)) { + if (!client || connectionStatuses.get(targetServerId) !== "online") { unreachable.push({ serverId: targetServerId, serverName }); continue; } @@ -255,7 +256,7 @@ export function useAgentHistory(options: { } return { targetHosts: hosts, unreachableHosts: unreachable }; - }, [daemons, runtime, runtimeVersion, serverId]); + }, [daemons, runtime, connectionStatuses, serverIds]); const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]); // One gate, checked before the field is offered: a fleet where any host // predates search has no search, rather than a list that silently omits that diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 3259631992..e3ece6990f 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -129,7 +129,7 @@ describe("loadOlderAgentHistory", () => { expect(client.calls).toEqual([]); expect(inFlight.values).toEqual([true]); - expect(started).toBe(true); + expect(started).toBe(false); }); it("requests the page before the current start cursor and clears in-flight on success", async () => { @@ -153,6 +153,7 @@ describe("loadOlderAgentHistory", () => { limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", wholeTurns: true, + timeout: 10_000, }, }, ]); @@ -169,7 +170,7 @@ describe("loadOlderAgentHistory", () => { const toast = createToast(); const logger = createLogger(); - await loadOlderAgentHistory(agentId, { + const applied = await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: true, @@ -179,6 +180,7 @@ describe("loadOlderAgentHistory", () => { logger, }); + expect(applied).toBe(false); expect(client.calls).toHaveLength(1); expect(toast.shown).toEqual([ { diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 2bc3f5c9df..a3887dd7b2 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -18,6 +18,7 @@ export interface LoadOlderAgentHistoryClient { cursor: { epoch: string; seq: number }; limit: number; projection: "projected"; + timeout: number; }, ) => Promise; } @@ -44,7 +45,7 @@ export async function loadOlderAgentHistory( const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } = deps; if (isLoadingOlder) { - return true; + return false; } if (!client || !cursor || !hasOlder) { return false; @@ -52,16 +53,17 @@ export async function loadOlderAgentHistory( setInFlight(true); try { - await client.fetchAgentTimeline( - agentId, - planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }), - ); + await client.fetchAgentTimeline(agentId, { + ...planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }), + timeout: 10_000, + }); } catch (error) { (logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error); toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), { durationMs: 2200, testID: "agent-load-older-history-toast", }); + return false; } finally { setInFlight(false); } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index bcb4322b89..ccae42afbe 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1016,6 +1016,7 @@ function ChatAgentContent({ effectiveAgent={effectiveAgent} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} + hasCachedHistory={replicaTimelineStatus === "painted"} toastApi={toastApi} toast={toastState} dismiss={dismissToast} @@ -1047,6 +1048,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ effectiveAgent, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, + hasCachedHistory, toastApi, toast, dismiss, @@ -1075,6 +1077,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ effectiveAgent: AgentScreenAgent | null; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; + hasCachedHistory: boolean; toastApi: ToastApi; toast: ToastState | null; dismiss: () => void; @@ -1202,6 +1205,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ draft={draft} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} + isInitialHistoryLoading={ + routeBottomAnchorRequest?.reason === "initial-entry" && + !hasCachedHistory && + !showHistorySyncError + } + revealInitialHistory={hasCachedHistory || showHistorySyncError} hasActiveComposer={hasActiveComposer} toast={toastApi} onOpenWorkspaceFile={onOpenWorkspaceFile} @@ -1363,6 +1372,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ draft, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, + isInitialHistoryLoading, + revealInitialHistory, hasActiveComposer, toast, onOpenWorkspaceFile, @@ -1374,6 +1385,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ draft: DraftConversation | null; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; + isInitialHistoryLoading: boolean; + revealInitialHistory: boolean; hasActiveComposer: boolean; toast: ReturnType["api"]; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; @@ -1464,6 +1477,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ pendingPermissions={pendingPermissions} routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} + isInitialHistoryLoading={isInitialHistoryLoading} + revealInitialHistory={revealInitialHistory} bottomOverlayTailClearance={bottomOverlayTailClearance} bottomOverlayControlClearance={bottomOverlayControlClearance} toast={toast} diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 377d5331c8..039595b6a4 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -26,6 +26,7 @@ import { providerSubagentLifecycleStatus, refreshProviderSubagents, observeProviderSubagentTimeline, + loadOlderProviderSubagentTimeline, useProviderSubagentStore, } from "@/subagents/provider-store"; import { useTranslation } from "react-i18next"; @@ -177,26 +178,24 @@ function ProviderSubagentPanel() { }); }, [client, supported, serverId, target.parentAgentId, target.subagentId]); - const loadOlder = useCallback((): boolean => { + const loadOlder = useCallback(async (): Promise => { if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) { return false; } - const firstSeq = timeline.cursor?.startSeq ?? null; - if (firstSeq === null) return false; setIsLoadingOlder(true); - void client - .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { - direction: "before", - cursor: { epoch: timeline.epoch, seq: firstSeq }, + try { + return await loadOlderProviderSubagentTimeline({ + client, + serverId, + parentAgentId: target.parentAgentId, + subagentId: target.subagentId, limit: TIMELINE_FETCH_PAGE_SIZE, - }) - .then((payload) => { - useProviderSubagentStore.getState().replaceTimeline(serverId, payload); - return undefined; - }) - .catch(() => undefined) - .finally(() => setIsLoadingOlder(false)); - return true; + }); + } catch { + return false; + } finally { + setIsLoadingOlder(false); + } }, [ client, isLoadingOlder, diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index d1c5205fb1..4670191746 100644 --- a/packages/app/src/plugins/timeline/projection.ts +++ b/packages/app/src/plugins/timeline/projection.ts @@ -65,6 +65,7 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null { ...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}), }; case "plugin": + case "turn_summary": return null; } } diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index 03fc0632a9..30d2053279 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -8,6 +8,8 @@ import { } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; +import { useDraftStore } from "@/stores/draft-store"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; import { AgentDirectoryReplica } from "./agent-replica"; function payload(title: string): AgentSnapshotPayload { @@ -58,7 +60,7 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { describe("AgentDirectoryReplica", () => { it.each(["delta", "snapshot"] as const)( - "does not erase freshly fetched history when the active directory removes it by %s", + "preserves history and unsent drafts when the active directory removes an agent by %s", (removal) => { const serverId = `agent-replica-history-${removal}`; const store = useSessionStore.getState(); @@ -68,6 +70,18 @@ describe("AgentDirectoryReplica", () => { () => undefined, () => undefined, ); + const draftKey = buildDraftStoreKey({ serverId, agentId: "agent" }); + const draft = { + text: "Keep this unsent draft", + attachments: [ + { + kind: "workspace_file" as const, + path: "README.md", + selection: { kind: "whole_file" as const }, + }, + ], + }; + useDraftStore.getState().saveDraftInput({ draftKey, draft }); const archived = { ...payload("archived"), archivedAt: "2026-09-11T00:00:00.000Z" }; replica.submitTimelineAgent(replica.captureTimeline("agent"), archived); store.applyAgentTimelineResponseState(serverId, "agent", { @@ -93,7 +107,9 @@ describe("AgentDirectoryReplica", () => { status: "synced", items: [{ text: "server history" }], }); + expect(useDraftStore.getState().getDraftInput(draftKey)).toEqual(draft); replica.remove("agent"); + expect(useDraftStore.getState().getDraftInput(draftKey)).toBeUndefined(); expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ status: "cold", }); diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index 6be4b5e9a5..1b3f24bcd6 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -190,9 +190,6 @@ export class AgentStoreProjection { agentLastActivity.delete(agentId); return { ...state, agentLastActivity }; }); - useDraftStore.getState().clearDraftInput({ - draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }), - }); const initKey = getInitKey(this.serverId, agentId); if (getInitDeferred(initKey)) { rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); @@ -201,6 +198,9 @@ export class AgentStoreProjection { remove(agentId: string): void { this.removeFromDirectory(agentId); + useDraftStore.getState().clearDraftInput({ + draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }), + }); // Only entity deletion destroys the transcript. A filtered active-list // response can arrive after a fresh history response for an archived agent. const store = useSessionStore.getState(); diff --git a/packages/app/src/runtime/host-logger.test.ts b/packages/app/src/runtime/host-logger.test.ts new file mode 100644 index 0000000000..fa53ca7668 --- /dev/null +++ b/packages/app/src/runtime/host-logger.test.ts @@ -0,0 +1,72 @@ +import { expect, it } from "vitest"; +import { createHostLogger } from "./host-logger"; + +it("preserves the original transport cause and tolerates a cyclic cause chain", () => { + const lines: string[] = []; + const capture = (...values: unknown[]) => lines.push(values.map(String).join(" ")); + const logger = createHostLogger(() => "test-host", { + log: capture, + warn: capture, + error: capture, + }); + const transport = new Error("socket closed"); + const wrapper = new Error("Subscription failed", { cause: transport }); + Object.defineProperty(transport, "cause", { value: wrapper }); + logger.error({ err: wrapper }, "subscription_error"); + const record = JSON.parse(lines[0]!.slice("subscription_error ".length)); + expect(record.err.cause.message).toBe("socket closed"); + expect(record.err.cause.stack).toBe(transport.stack); + expect(record.err.cause.cause).toBe("[Circular]"); +}); + +it("retains host and error details in the desktop's text-only console capture", () => { + const lines: { level: string; text: string }[] = []; + const capture = + (level: string) => + (...values: unknown[]) => { + lines.push({ level, text: values.map(String).join(" ") }); + }; + const logger = createHostLogger(() => "offline-host", { + log: capture("info"), + warn: capture("warn"), + error: capture("error"), + }); + logger.warn( + { err: { name: "Error", message: "malformed ready envelope" } }, + "relay_e2ee_channel_error", + ); + expect(lines).toEqual([ + { + level: "warn", + text: 'relay_e2ee_channel_error {"serverId":"offline-host","err":{"name":"Error","message":"malformed ready envelope"}}', + }, + ]); +}); + +it("keeps native Error messages and severity without failing on circular diagnostics", () => { + const lines: { level: string; text: string }[] = []; + const capture = + (level: string) => + (...values: unknown[]) => { + lines.push({ level, text: values.map(String).join(" ") }); + }; + const logger = createHostLogger(() => "test-host", { + log: capture("info"), + warn: capture("warn"), + error: capture("error"), + }); + const record: { err: Error; self?: object } = { err: new Error("connection lost") }; + record.self = record; + logger.error(record, "Connection failed"); + expect(lines[0]?.level).toBe("error"); + expect(lines[0]?.text).toContain('"message":"connection lost"'); + expect(lines[0]?.text).toContain('"serverId":"test-host"'); + expect(lines[0]?.text).toContain('"[Circular]"'); + logger.info({ status: "offline" }, "Host status"); + expect(lines[1]).toEqual({ + level: "info", + text: 'Host status {"serverId":"test-host","status":"offline"}', + }); + logger.debug({ status: "offline" }, "Host status"); + expect(lines).toHaveLength(2); +}); diff --git a/packages/app/src/runtime/host-logger.ts b/packages/app/src/runtime/host-logger.ts new file mode 100644 index 0000000000..3950038600 --- /dev/null +++ b/packages/app/src/runtime/host-logger.ts @@ -0,0 +1,36 @@ +import type { Logger } from "@getpaseo/client/internal/daemon-client"; + +export function createHostLogger( + readServerId: () => string | undefined, + output: Pick = console, +): Logger { + const format = (record: object, message?: string): string => { + const seen = new WeakSet(); + const details = JSON.stringify( + { serverId: readServerId(), ...record }, + (_key, value: unknown) => { + if (value && typeof value === "object") { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + if (value instanceof Error) { + return { + ...value, + name: value.name, + message: value.message, + stack: value.stack, + cause: value.cause, + }; + } + } + return value; + }, + ); + return message ? `${message} ${details}` : details; + }; + return { + debug: () => {}, + info: (record, message) => output.log(format(record, message)), + warn: (record, message) => output.warn(format(record, message)), + error: (record, message) => output.error(format(record, message)), + }; +} diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index b6d10dc8c7..394c61d4bf 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,5 +1,6 @@ import { createHostPluginClientBundleStore } from "@/plugins/client-bundles"; import { watchdog } from "@/watchdog"; +import { createHostLogger } from "./host-logger"; import { createHostChannelTransport } from "@getpaseo/client/internal/hosts"; import type { OwnedSubscription } from "@getpaseo/client"; import type { HostDirectory } from "@getpaseo/protocol/hosts"; @@ -589,6 +590,7 @@ function createDefaultDeps( runtimeGeneration, capabilities: appCapabilities, trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), + logger: createHostLogger(() => host.serverId), providerSnapshots: "wire", createPluginClientBundleStore: createHostPluginClientBundleStore, ...(host.password ? { password: host.password } : {}), @@ -669,6 +671,7 @@ function createDefaultDeps( null, ), reconnect: { enabled: false }, + logger: createHostLogger(() => host.serverId), appVersion: resolveAppVersion() ?? undefined, createPluginClientBundleStore: createHostPluginClientBundleStore, }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 044bed232a..aa52a1b0c5 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -505,6 +505,9 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { version: item.version, data: item.data, }; + // A folded turn's summary row is presentation, not timeline. + case "turn_summary": + return null; } } diff --git a/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx index 668f6345a8..220e8dff2e 100644 --- a/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx +++ b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx @@ -2,12 +2,17 @@ import { useCallback, useState, type ReactNode } from "react"; import { View, type LayoutChangeEvent } from "react-native"; import { selectWorkspaceChrome } from "./workspace-tab-layout"; import { WorkspaceTabMeasurementScope } from "./workspace-desktop-tabs-row"; +import type { WorkspaceTabDescriptor } from "./workspace-tabs-types"; +/** + * Shows `children` (full header and tab rail) or `compactHeader` below the compact width. + * Without a compact header it always shows `children`, keeping the same tree so they never remount. + */ export function AdaptiveWorkspaceChrome({ compactHeader, children, }: { - compactHeader: ReactNode; + compactHeader: ReactNode | null; children: ReactNode; }) { const [width, setWidth] = useState(null); @@ -18,12 +23,21 @@ export function AdaptiveWorkspaceChrome({ return ( - {selectWorkspaceChrome({ width, compact: compactHeader, full: children })} + {compactHeader === null + ? children + : selectWorkspaceChrome({ width, compact: compactHeader, full: children })} ); } +/** One pane's tabs, as its compact tab switcher and pane menu need them. */ +export interface WorkspaceCompactHeaderTabs { + tabs: WorkspaceTabDescriptor[]; + activeTabKey: string; + actions: WorkspaceCompactHeaderActions; +} + export interface WorkspaceCompactHeaderActions { pane?: { id: string; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 6ba87ac884..c1c375fbae 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,6 +1,6 @@ import { AdaptiveWorkspaceChrome, - type WorkspaceCompactHeaderActions, + type WorkspaceCompactHeaderTabs, } from "./adaptive-workspace-chrome"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useHostFeature } from "@/runtime/host-features"; @@ -3389,11 +3389,11 @@ function WorkspaceScreenContent({ ], ); const renderWorkspaceTabSwitcher = useCallback( - ( - headerTabs: WorkspaceTabDescriptor[], - headerActiveTabKey: string, - actions: WorkspaceCompactHeaderActions, - ) => { + ({ + tabs: headerTabs, + activeTabKey: headerActiveTabKey, + actions, + }: WorkspaceCompactHeaderTabs) => { return ( { - const tabSwitcher = renderWorkspaceTabSwitcher(headerTabs, headerActiveTabKey, actions); + (paneTabs: WorkspaceCompactHeaderTabs | null, actionsOnly = false) => { + const tabSwitcher = paneTabs ? renderWorkspaceTabSwitcher(paneTabs) : null; return ( { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { return null; diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index 01d6880d5b..a28e9b00fd 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -1,7 +1,9 @@ +import { createHistoryStartPagination } from "@/agent-stream/history-start-pagination"; import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; import { + loadOlderProviderSubagentTimeline, observeProviderSubagentTimeline, providerSubagentKey, refreshProviderSubagents, @@ -565,6 +567,95 @@ describe("projected child history", () => { .getState() .replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction)); } + test("older child history completes only after the delayed page is applied", async () => { + const tail = { ...response("B", 2, 2), hasOlder: true }; + useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, tail); + let deliver!: (payload: Payload) => void; + const client = { + fetchProviderSubagentTimeline: () => + new Promise((resolve) => { + deliver = resolve; + }), + }; + let completed = false; + const request = loadOlderProviderSubagentTimeline({ + client, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + }).then((result) => { + completed = true; + return result; + }); + await Promise.resolve(); + expect(completed).toBe(false); + deliver({ ...response("A", 1, 1), direction: "before" }); + expect(await request).toBe(true); + expect(current().cursor?.startSeq).toBe(1); + }); + test("bounds an older child request so a timeout permits another attempt", async () => { + vi.useFakeTimers(); + try { + useProviderSubagentStore + .getState() + .replaceTimeline(SERVER_ID, { ...response("B", 2, 2), hasOlder: true }); + const timeouts: Array = []; + const client = { + fetchProviderSubagentTimeline: ( + _parent: string, + _child: string, + options: { timeout?: number }, + ) => { + timeouts.push(options.timeout); + return new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("timeout")), options.timeout ?? 60_000), + ); + }, + }; + let loading = false; + const pagination = createHistoryStartPagination({ + initiallyVisible: true, + read: () => ({ + distanceFromHistoryStart: 0, + isUnderfilled: true, + hasOlderHistory: true, + isLoadingOlderHistory: loading, + isReady: true, + progressKey: "e:2", + }), + load: async () => { + loading = true; + try { + return await loadOlderProviderSubagentTimeline({ + client, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + }); + } catch { + return false; + } finally { + loading = false; + } + }, + changed: () => {}, + scheduleSettle: () => {}, + }); + pagination.update(); + expect(timeouts).toEqual([10_000]); + await vi.advanceTimersByTimeAsync(10_000); + expect(pagination.state.status).toBe("failed"); + pagination.rearm(); + expect(timeouts).toEqual([10_000, 10_000]); + expect(pagination.state.status).toBe("loading"); + pagination.dispose(); + await vi.advanceTimersByTimeAsync(10_000); + } finally { + vi.useRealTimers(); + } + }); test("replaces overlapping fetched text and continues live streaming", () => { page("AB", 1, 2); stream(3, "C"); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index fd44dae8d2..48010aa0be 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -355,6 +355,35 @@ export const useProviderSubagentStore = create((set) => ( })); /** Owns child history bootstrap and recovery while a pane observes the child. */ +export async function loadOlderProviderSubagentTimeline({ + client, + serverId, + parentAgentId, + subagentId, + limit, +}: { + client: Pick; + serverId: string; + parentAgentId: string; + subagentId: string; + limit: number; +}): Promise { + const timeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(serverId, parentAgentId, subagentId)); + const seq = timeline?.cursor?.startSeq; + if (!timeline?.hasOlder || !timeline.epoch || seq == null) return false; + const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, { + direction: "before", + cursor: { epoch: timeline.epoch, seq }, + limit, + timeout: 10_000, + }); + if (payload.error) return false; + useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + return true; +} + export function observeProviderSubagentTimeline({ client, serverId, diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 623deb0ac5..9ca830918a 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -8,6 +8,7 @@ import type { import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata } from "@/attachments/types"; +import type { OverviewSummary } from "@/tool-calls/detail-level/overview/model"; import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers"; /** @@ -86,7 +87,8 @@ export type StreamItem = | TodoListItem | NotificationItem | CompactionItem - | PluginTimelineStreamItem; + | PluginTimelineStreamItem + | TurnSummaryItem; export type UserMessageImageAttachment = AttachmentMetadata; @@ -824,6 +826,25 @@ export interface PluginTimelineStreamItem { data: JsonValue; } +/** + * The row a folded turn leaves between its host and its reply, standing in for the work it + * hides. Turn collapse produces it from the loaded rows; it is never stored. + */ +export interface TurnSummaryItem { + kind: "turn_summary"; + id: string; + timelineCursor?: TimelinePosition; + turnId?: string; + timestamp: Date; + /** + * The prompt the turn folds under, or its first loaded tool call when the prompt is above the + * loaded history. Keys the reader's expanded turns. + */ + hostId: string; + expanded: boolean; + tools: OverviewSummary; +} + export interface TodoEntry { text: string; completed: boolean; diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index 29382b5c89..88f36c6d0f 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client"; import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection"; +import { createHostLogger } from "@/runtime/host-logger"; class FakeDaemonClient implements DaemonProbeClient { readonly lastError: string | null; + private connected = false; constructor( private readonly probe: FakeDaemonProbe, @@ -17,9 +19,11 @@ class FakeDaemonClient implements DaemonProbeClient { if (this.probe.nextConnectError) { throw this.probe.nextConnectError; } + this.connected = true; } getLastServerInfoMessage() { + if (!this.connected) return null; return { serverId: "srv_probe_test", hostname: "probe-host", @@ -27,11 +31,13 @@ class FakeDaemonClient implements DaemonProbeClient { } async close(): Promise { + this.connected = false; this.probe.closedClients.push(this); } } class FakeDaemonProbe { + diagnosticLines: string[] = []; createdClients: FakeDaemonClient[] = []; closedClients: FakeDaemonClient[] = []; clientIdsRequested = 0; @@ -39,6 +45,12 @@ class FakeDaemonProbe { nextLastError: string | null = null; readonly deps: DaemonConnectionDependencies = { + createLogger: (readServerId) => { + const capture = (...values: unknown[]) => { + this.diagnosticLines.push(values.map(String).join(" ")); + }; + return createHostLogger(readServerId, { log: capture, warn: capture, error: capture }); + }, getClientId: async () => { this.clientIdsRequested += 1; return "cid_shared_probe_test"; @@ -76,6 +88,34 @@ describe("test-daemon-connection connectToDaemon", () => { probe = new FakeDaemonProbe(); }); + it.each([undefined, "srv_probe_test"])( + "logs learned host identity on an adopted probe initially given %s", + async (serverId) => { + const { connectToDaemon } = await import("./test-daemon-connection"); + const result = await connectToDaemon( + { id: "direct:test", type: "directTcp", endpoint: "localhost:6767" }, + { serverId }, + probe.deps, + ); + try { + result.client.config.logger!.error({ err: new Error("socket closed") }, "connection_error"); + const record = JSON.parse(probe.diagnosticLines[0]!.slice("connection_error ".length)); + expect(record.serverId).toBe("srv_probe_test"); + expect(record.err.message).toBe("socket closed"); + await result.client.close(); + expect(result.client.getLastServerInfoMessage()).toBeNull(); + result.client.config.logger!.error({ err: new Error("disconnected") }, "connection_error"); + const closedRecord = JSON.parse( + probe.diagnosticLines[1]!.slice("connection_error ".length), + ); + expect(closedRecord.serverId).toBe("srv_probe_test"); + expect(closedRecord.err.message).toBe("disconnected"); + } finally { + await result.client.close(); + } + }, + ); + it.each([ { id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" }, { diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 1d1605af62..96400b4878 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -7,6 +7,7 @@ import { import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import type { HostConnection } from "@/types/host-connection"; import { createAppWebSocketFactory } from "@/runtime/websocket-factory"; +import { createHostLogger } from "@/runtime/host-logger"; import { getOrCreateClientId } from "./client-id"; import { resolveAppVersion } from "./app-version"; import { @@ -29,6 +30,7 @@ export interface DaemonProbeClient { } export interface DaemonConnectionDependencies { + createLogger: typeof createHostLogger; getClientId(): Promise; resolveAppVersion(): string | null; createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null; @@ -37,6 +39,7 @@ export interface DaemonConnectionDependencies } const defaultDaemonConnectionDependencies: DaemonConnectionDependencies = { + createLogger: createHostLogger, getClientId: getOrCreateClientId, resolveAppVersion, createDesktopTransportFactory: createDesktopDaemonTransportFactory, @@ -317,5 +320,9 @@ export async function connectToDaemon( deps: DaemonConnectionDependencies = defaultDaemonConnectionDependencies, ): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> { const config = await buildClientConfig(connection, options?.serverId, options, deps); - return connectAndProbe(config, resolveTimeout(connection, options), deps); + let serverId = options?.serverId; + config.logger = deps.createLogger(() => serverId); + const result = await connectAndProbe(config, resolveTimeout(connection, options), deps); + serverId = result.serverId; + return result; } diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index af3130015d..1cff249d47 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -122,3 +122,11 @@ export const Shield = StubIcon; export const ShieldEllipsis = StubIcon; export const ShieldPlus = StubIcon; export const Zap = StubIcon; +export const CheckSquare = StubIcon; +export const Circle = StubIcon; +export const FileSymlink = StubIcon; +export const MessageSquareCode = StubIcon; +export const MicVocal = StubIcon; +export const Scissors = StubIcon; +export const Sparkles = StubIcon; +export const TriangleAlertIcon = StubIcon; diff --git a/packages/app/test-stubs/react-native.mjs b/packages/app/test-stubs/react-native.mjs new file mode 100644 index 0000000000..9c72575d62 --- /dev/null +++ b/packages/app/test-stubs/react-native.mjs @@ -0,0 +1,14 @@ +// react-native-web has no ToastAndroid. The toast host imports it by name for its Android +// branch; Metro and Node tolerate the missing binding, but browser tests load native ESM, +// which rejects the import and fails every module that reaches the toast context. +export * from "react-native-web/dist/index.js"; + +export const ToastAndroid = { + SHORT: 0, + LONG: 1, + TOP: 0, + BOTTOM: 1, + CENTER: 2, + show() {}, + showWithGravity() {}, +}; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 5df660ba1d..c13780d6b6 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -261,12 +261,12 @@ export default defineConfig({ "@floating-ui/react-native/dist/floating-ui.react-native.esm.js", ), }, - // Point to the ESM build so Vite can transform its imports and apply the + // Point to react-native-web's ESM build so Vite can transform its imports and apply the // react alias below (the CJS build uses require('react') which bypasses - // Vite alias resolution). + // Vite alias resolution). The stub re-exports it and adds what web lacks. { find: "react-native", - replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"), + replacement: path.resolve(__dirname, "test-stubs/react-native.mjs"), }, { find: "react", replacement: resolvePackageEntry("react") }, { diff --git a/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts index 9090456406..cb272cf54a 100644 --- a/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts +++ b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts @@ -49,7 +49,7 @@ const paseoHome = join(root, "home"); const cliHome = join(root, "os-home"); const workDir = join(root, "work"); for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true }); -const currentProtocolVersion = 5; +const currentProtocolVersion = 6; const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null; interface Installation { @@ -456,8 +456,8 @@ try { }); console.log(`Competing listener owns released port ${releasedPort}`); - // There is no supported previous wire at protocol 5. Future previous-version classification - // is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter. + // Protocols 0–4 have no draining adapter. Protocol 5 is now the supported previous wire; + // its classification is covered by terminal-worker-protocol.test.ts. for (const protocolVersion of [0, 1, 2, 3, 4]) { console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`); const obsolete = await leaveWorkerWithProtocol( diff --git a/packages/client/package.json b/packages/client/package.json index 810cb8b0c9..d128a3f007 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -45,7 +45,8 @@ "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", "typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit", - "test": "vitest run" + "test": "vitest run", + "measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts" }, "dependencies": { "@getpaseo/protocol": "0.15.3", diff --git a/packages/client/scripts/measure-diff-materialization.ts b/packages/client/scripts/measure-diff-materialization.ts new file mode 100644 index 0000000000..54f7b1f53a --- /dev/null +++ b/packages/client/scripts/measure-diff-materialization.ts @@ -0,0 +1,38 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { performance } from "node:perf_hooks"; +import { decodeDiffFrame } from "@getpaseo/protocol/binary-frames/index"; +import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound"; +import { DiffStream } from "../src/diff/stream.js"; +const artifacts = resolve(import.meta.dirname, "../../../.artifacts/diff-subscription"); +const results = []; +for (const count of [1, 30]) { + const corpus = JSON.parse(await readFile(resolve(artifacts, `corpus-${count}.json`), "utf8")) as { + frames: string[]; + legacy: string; + }; + const frames = corpus.frames.map((value) => Buffer.from(value, "base64")); + const binary: number[] = []; + const json: number[] = []; + for (let iteration = 0; iteration < 15; iteration++) { + let files = 0; + const stream = new DiffStream((state) => { + if (state.phase === "ready") files = state.files.length; + }); + let start = performance.now(); + for (const frame of frames) stream.receive(decodeDiffFrame(frame)); + binary.push(performance.now() - start); + start = performance.now(); + validateWSOutboundMessage(JSON.parse(corpus.legacy)); + json.push(performance.now() - start); + if (!files) throw new Error("No materialized files"); + } + const median = (values: number[]) => + values.slice(3).sort((a, b) => a - b)[Math.floor((values.length - 3) / 2)]; + const result = { count, materializationMs: median(binary), jsonParseValidationMs: median(json) }; + results.push(result); + console.log(JSON.stringify(result)); +} +await writeFile(resolve(artifacts, "client-metrics.json"), JSON.stringify(results, null, 2)); +if (results.some((result) => result.materializationMs > result.jsonParseValidationMs)) + process.exitCode = 1; diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index c95c534129..b9ea5b0454 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -1,5 +1,9 @@ import { EventEmitter } from "node:events"; import { describe, expect, test, vi } from "vitest"; +import { WebSocketServer, type WebSocket as ServerSocket } from "ws"; +import { exportPublicKey, generateKeyPair } from "@getpaseo/relay/e2ee"; +import { DaemonClient } from "./daemon-client.js"; +import { defaultWebSocketFactory } from "./daemon-client-websocket-transport.js"; import { createEncryptedTransport, createWebSocketTransportFactory, @@ -190,3 +194,103 @@ test("native drain capability survives WebSocket and encryption wrappers", async }); expect(encrypted.supportsDrain).toBe(false); }); + +interface ObservedSocket { + closeFrame: { code: number; reason: string } | null; +} + +/** A daemon endpoint that accepts sockets and never answers, so every attempt times out. */ +async function silentDaemon() { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve) => server.once("listening", () => resolve())); + const sockets: ObservedSocket[] = []; + server.on("connection", (socket: ServerSocket) => { + const observed: ObservedSocket = { closeFrame: null }; + sockets.push(observed); + socket.on("close", (code, reason) => { + observed.closeFrame = { code, reason: reason.toString() }; + }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing port"); + return { + url: `ws://127.0.0.1:${address.port}/ws`, + sockets, + openSockets: () => sockets.filter((entry) => entry.closeFrame === null), + stop: () => + new Promise((resolve) => { + for (const client of server.clients) client.terminate(); + server.close(() => resolve()); + }), + }; +} + +// Node's global WebSocket applies the WHATWG close rules Chromium and Electron apply: +// close() accepts only 1000 or 3000-4999 and a reason of at most 123 UTF-8 bytes. +describe("abandoned connections close their WebSocket", () => { + test.each([ + { name: "direct", e2ee: false }, + { name: "relay E2EE", e2ee: true }, + ])( + "$name: a timed-out attempt sends a close frame before the client reconnects", + async ({ e2ee }) => { + const daemon = await silentDaemon(); + const client = new DaemonClient({ + url: daemon.url, + clientId: "cid_abandoned_close_test", + webSocketFactory: defaultWebSocketFactory, + connectTimeoutMs: 1_500, + reconnect: { enabled: true, baseDelayMs: 50, maxDelayMs: 50 }, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + ...(e2ee + ? { + e2ee: { + enabled: true, + daemonPublicKeyB64: exportPublicKey(generateKeyPair().publicKey), + }, + } + : {}), + }); + try { + void client.connect().catch(() => {}); + await vi.waitFor(() => expect(daemon.sockets.length).toBeGreaterThanOrEqual(2), { + timeout: 4_000, + }); + const [abandoned] = daemon.sockets; + await vi.waitFor(() => expect(abandoned?.closeFrame).not.toBeNull(), { timeout: 500 }); + expect(abandoned?.closeFrame?.reason).toBe("Connection timed out"); + + // The newest attempt may still be connecting; aborting it closes without a frame. + await client.close(); + await vi.waitFor(() => expect(daemon.openSockets()).toEqual([]), { timeout: 500 }); + } finally { + await client.close(); + await daemon.stop(); + } + }, + 10_000, + ); + + test("a code or reason the WebSocket API rejects still closes the socket", async () => { + const daemon = await silentDaemon(); + try { + const transport = createWebSocketTransportFactory(defaultWebSocketFactory)({ + url: daemon.url, + }); + await new Promise((resolve) => transport.onOpen(() => resolve())); + const reason = `Decryption failed: ${"é".repeat(100)}`; + + expect(() => transport.close(1011, reason)).not.toThrow(); + + await vi.waitFor(() => expect(daemon.sockets[0]?.closeFrame).not.toBeNull(), { + timeout: 500, + }); + const frame = daemon.sockets[0]?.closeFrame; + expect(frame?.code).toBe(1000); + expect(reason.startsWith(frame?.reason ?? "")).toBe(true); + expect(Buffer.byteLength(frame?.reason ?? "")).toBe(123); + } finally { + await daemon.stop(); + } + }); +}); diff --git a/packages/client/src/daemon-client-websocket-transport.ts b/packages/client/src/daemon-client-websocket-transport.ts index c7ec87555d..5331aea9f4 100644 --- a/packages/client/src/daemon-client-websocket-transport.ts +++ b/packages/client/src/daemon-client-websocket-transport.ts @@ -82,7 +82,7 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem // connect timeouts does not crash the CLI with an unhandled error event. const suppressEarlyCloseError = bindTemporaryEarlyCloseErrorHandler(ws); try { - ws.close(code, reason); + ws.close(toSendableCloseCode(code), toSendableCloseReason(reason)); } finally { if (typeof ws.on !== "function" && typeof ws.addEventListener !== "function") { suppressEarlyCloseError(); @@ -97,6 +97,32 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem }; } +// WHATWG WebSocket.close throws for a code other than 1000 or 3000-4999 and for a +// reason over 123 UTF-8 bytes, and the throw leaves the socket open: the peer keeps +// streaming into a connection nobody reads. Browsers, Electron and Node's global +// WebSocket enforce this; Node's `ws` accepts more. Translate once here so callers +// keep their reasons and every runtime sends the same close frame. +const MAX_CLOSE_REASON_BYTES = 123; + +function toSendableCloseCode(code: number | undefined): number { + if (code === 1000 || (code !== undefined && code >= 3000 && code <= 4999)) return code; + return 1000; +} + +function toSendableCloseReason(reason: string | undefined): string | undefined { + if (reason === undefined) return undefined; + const encoder = new TextEncoder(); + if (encoder.encode(reason).byteLength <= MAX_CLOSE_REASON_BYTES) return reason; + let truncated = ""; + let bytes = 0; + for (const character of reason) { + bytes += encoder.encode(character).byteLength; + if (bytes > MAX_CLOSE_REASON_BYTES) break; + truncated += character; + } + return truncated; +} + function bindWsMessageHandler( ws: WebSocketLike, handler: (data: unknown, isBinary: boolean) => void, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index fa12b94048..10341bdcb6 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -728,6 +728,10 @@ class FakeDaemon { this.onClose({ reason }); } + daemonSendsPong(): void { + this.onMessage(JSON.stringify({ type: "pong" })); + } + triggerError(event?: unknown): void { this.onError(event); } @@ -2605,7 +2609,7 @@ test("keeps default connect timeout shorter than session RPC waiters", async () } }); -test("foreground checks a silently broken socket through two normal heartbeat misses", async () => { +test("foreground tears down a silently dead socket after two fresh five-second windows and reconnects at once", async () => { useHeartbeatClock(); const first = new FakeDaemon(); const second = new FakeDaemon(); @@ -2623,14 +2627,23 @@ test("foreground checks a silently broken socket through two normal heartbeat mi await connection; client.ensureConnected({ verify: true }); - await vi.advanceTimersByTimeAsync(15_000); + expect(first.pingTimestamps()).toEqual(["0s"]); + await vi.advanceTimersByTimeAsync(5_000); + // The first silent window is followed by a second check at once, not ten seconds later. expect(first.closesFromClient()).toEqual([]); - await vi.advanceTimersByTimeAsync(25_000); + expect(first.pingTimestamps()).toEqual(["0s", "5s"]); + await vi.advanceTimersByTimeAsync(4_999); + expect(first.closesFromClient()).toEqual([]); + await vi.advanceTimersByTimeAsync(1); expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); - client.ensureConnected(); + // The reconnect is immediate: no 1.5s backoff after a foreground liveness teardown. expect(attempts).toBe(2); second.openConnection(); expect(client.getConnectionState()).toEqual({ status: "connected" }); + // The replacement runs the normal heartbeat cadence. + await vi.advanceTimersByTimeAsync(10_000); + expect(second.pingTimestamps()).toEqual(["20s"]); + expect(second.closesFromClient()).toEqual([]); }); test("foreground verification preserves a healthy socket and deduplicates simultaneous checks", async () => { @@ -2675,22 +2688,26 @@ test("chunks prevent heartbeat misses until silence causes two misses", async () void client.measureLatency({ timeoutMs: 5_000 }).catch((error: unknown) => { measurementError = error; }); - await vi.advanceTimersByTimeAsync(5_000); + // Chunks complete every four seconds for the first 36 seconds; no pong ever arrives. + for (let index = 0; index < 9; index++) { + await vi.advanceTimersByTimeAsync(4_000); + daemon.receiveChunk(index === 0); + expect(daemon.closesFromClient()).toEqual([]); + } expect(measurementError).toEqual( expect.objectContaining({ message: "Latency measurement timed out (5000ms)", }), ); - for (let index = 0; index < 4; index++) { - await vi.advanceTimersByTimeAsync(index === 0 ? 5_000 : 10_000); - daemon.receiveChunk(index === 0); - expect(daemon.closesFromClient()).toEqual([]); - } - // No complete message or pong has arrived, but progress prevents misses. - expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]); - await vi.advanceTimersByTimeAsync(25_000); + // Progress inside the foreground window returned the heartbeat to its normal cadence. + expect(daemon.pingTimestamps()).toEqual(["0s", "15s"]); + await vi.advanceTimersByTimeAsync(19_000); expect(daemon.closesFromClient()).toEqual([]); - await vi.advanceTimersByTimeAsync(25_000); + expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s"]); + await vi.advanceTimersByTimeAsync(24_999); + expect(daemon.closesFromClient()).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s", "65s"]); expect(daemon.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); }); @@ -2712,13 +2729,14 @@ test("foreground preserves a multi-MB transfer with its pong queued behind chunk client.ensureConnected({ verify: true }); await vi.advanceTimersByTimeAsync(3_001); expect(daemon.closesFromClient()).toEqual([]); - for (let index = 0; index < 4; index++) { - await vi.advanceTimersByTimeAsync(10_000); + for (let index = 0; index < 5; index++) { + await vi.advanceTimersByTimeAsync(4_000); daemon.receiveChunk(index === 0); expect(daemon.closesFromClient()).toEqual([]); } - // No complete message or pong has arrived, but progress prevents misses. - expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]); + // The first chunk landed seven seconds after foreground: the first window was silent, the + // second saw progress, so the cycle ended without a teardown and the cadence is normal again. + expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "20s"]); daemon.finishTransfer(); await vi.advanceTimersByTimeAsync(30_000); expect(client.getConnectionState()).toEqual({ status: "connected" }); @@ -2742,13 +2760,14 @@ test("chunk progress beyond the daemon lease keeps sending heartbeat pings", asy await connection; client.ensureConnected({ verify: true }); for (let index = 0; index < 12; index++) { - await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(4_000); daemon.receiveChunk(index === 0); // Each completed window starts the next ping ten seconds later, // even though the response and all queued pongs are still in flight. + const now = (index + 1) * 4; const expected = ["0s"]; - if (index >= 4) expected.push("25s"); - if (index >= 9) expected.push("50s"); + if (now >= 15) expected.push("15s"); + if (now >= 40) expected.push("40s"); expect(daemon.pingTimestamps()).toEqual(expected); expect(daemon.closesFromClient()).toEqual([]); } @@ -2758,6 +2777,393 @@ test("chunk progress beyond the daemon lease keeps sending heartbeat pings", asy expect(daemon.closesFromClient()).toEqual([]); }); +function connectThroughReplacements( + clientId: string, + daemons: FakeDaemon[], + options?: { reconnect?: { enabled: boolean } }, +): { client: DaemonClient; attempts: () => number } { + let attempts = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId, + logger: noopLogger, + ...(options?.reconnect ? { reconnect: options.reconnect } : {}), + transportFactory: () => { + const daemon = daemons[Math.min(attempts, daemons.length - 1)]!; + attempts += 1; + return daemon.transport; + }, + }); + clients.push(client); + return { client, attempts: () => attempts }; +} + +test("foreground supersedes a heartbeat that straddled the suspension without counting a miss", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + const { client, attempts } = connectThroughReplacements("resume-before-expiry", [first, second]); + const connection = client.connect(); + first.openConnection(); + await connection; + first.daemonGoesSilent(); + await vi.advanceTimersByTimeAsync(10_000); // heartbeat ping outstanding when the OS suspends JS + await vi.advanceTimersByTimeAsync(14_990); // resume: AppState lands before the stale timer + client.ensureConnected({ verify: true }); + expect(first.pingTimestamps()).toEqual(["10s", "24.99s"]); + await vi.advanceTimersByTimeAsync(10); // the stale deadline passes without a miss + await vi.advanceTimersByTimeAsync(4_990); + expect(first.closesFromClient()).toEqual([]); + expect(first.pingTimestamps()).toEqual(["10s", "24.99s", "29.99s"]); + await vi.advanceTimersByTimeAsync(5_000); + expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); + expect(attempts()).toBe(2); +}); + +test("foreground does not inherit a miss recorded before the app was suspended", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + const { client, attempts } = connectThroughReplacements("resume-after-expiry", [first, second]); + const connection = client.connect(); + first.openConnection(); + await connection; + first.daemonGoesSilent(); + await vi.advanceTimersByTimeAsync(25_000); // ping at 10s missed at 25s, before or during suspension + client.ensureConnected({ verify: true }); + expect(first.pingTimestamps()).toEqual(["10s", "25s"]); + await vi.advanceTimersByTimeAsync(5_000); + // Two fresh windows: the old miss would have torn down here. + expect(first.closesFromClient()).toEqual([]); + expect(first.pingTimestamps()).toEqual(["10s", "25s", "30s"]); + await vi.advanceTimersByTimeAsync(5_000); + expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); + expect(attempts()).toBe(2); +}); + +test("a slow but healthy socket survives foreground after a miss before the suspension", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + const { client, attempts } = connectThroughReplacements("resume-slow-pong", [daemon]); + const connection = client.connect(); + daemon.openConnection(); + await connection; + daemon.daemonGoesSilent(); + await vi.advanceTimersByTimeAsync(25_000); // one miss + daemon.daemonAnswersPingsAfter("5.5s"); + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(60_000); + expect(daemon.closesFromClient()).toEqual([]); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(attempts()).toBe(1); +}); + +test("a pong that arrives after the first foreground window keeps the connection and restores the cadence", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonAnswersPingsAfter("5.5s"); + const { client, attempts } = connectThroughReplacements("slow-pong", [daemon]); + const connection = client.connect(); + daemon.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(5_000); + expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]); + await vi.advanceTimersByTimeAsync(55_000); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(daemon.closesFromClient()).toEqual([]); + expect(attempts()).toBe(1); + // After the late pong the heartbeat runs ten seconds after each settled probe again. + expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "15.5s", "31s", "46.5s"]); +}); + +test("a latency measurement outstanding at foreground keeps its own deadline, then the cycle runs", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("latency-then-foreground", [ + first, + second, + ]); + const connection = client.connect(); + first.openConnection(); + await connection; + const measurement = client.measureLatency({ timeoutMs: 1_000 }); + const measurementError = measurement.then( + () => null, + (error: unknown) => error, + ); + client.ensureConnected({ verify: true }); + expect(first.pingTimestamps()).toEqual(["0s"]); + await vi.advanceTimersByTimeAsync(1_000); + await expect(measurementError).resolves.toEqual( + expect.objectContaining({ message: "Latency measurement timed out (1000ms)" }), + ); + expect(first.closesFromClient()).toEqual([]); + await vi.advanceTimersByTimeAsync(5_000); + expect(first.pingTimestamps()).toEqual(["0s", "1s", "6s"]); + expect(first.closesFromClient()).toEqual([]); + await vi.advanceTimersByTimeAsync(5_000); + expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); + expect(attempts()).toBe(2); +}); + +test("a shared latency probe's activity from before foreground does not satisfy the fresh check", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("stale-activity", [first, second]); + const connection = client.connect(); + first.openConnection(); + await connection; + void client.measureLatency({ timeoutMs: 5_000 }).catch(() => {}); + first.receiveChunk(true); // a frame arrived while the measurement was outstanding, then the socket died + client.ensureConnected({ verify: true }); + expect(first.pingTimestamps()).toEqual(["0s"]); + await vi.advanceTimersByTimeAsync(5_000); + // The measurement's own deadline passed; the foreground cycle starts its first fresh window now. + expect(first.closesFromClient()).toEqual([]); + expect(first.pingTimestamps()).toEqual(["0s", "5s"]); + await vi.advanceTimersByTimeAsync(5_000); + expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]); + await vi.advanceTimersByTimeAsync(5_000); + expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); + expect(attempts()).toBe(2); +}); + +test("a latency measurement that joined a heartbeat probe survives the foreground check", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonAnswersPingsAfter("2s"); + const { client, attempts } = connectThroughReplacements("latency-joins-heartbeat", [daemon]); + const connection = client.connect(); + daemon.openConnection(); + await connection; + await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding + const measurement = client.measureLatency({ timeoutMs: 5_000 }); + await vi.advanceTimersByTimeAsync(1_000); + client.ensureConnected({ verify: true }); + expect(daemon.pingTimestamps()).toEqual(["10s", "11s"]); + // The heartbeat's pong lands at 12s and answers the measurement that joined it. + await vi.advanceTimersByTimeAsync(1_000); + await expect(measurement).resolves.toBe(2000); + await vi.advanceTimersByTimeAsync(19_000); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(daemon.closesFromClient()).toEqual([]); + expect(attempts()).toBe(1); + expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "22s"]); +}); + +test("a joined latency measurement outlives a silent foreground window until its own deadline", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonAnswersPingsAfter("7s"); + const { client, attempts } = connectThroughReplacements("latency-outlives-window", [daemon]); + const connection = client.connect(); + daemon.openConnection(); + await connection; + await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding + const measurement = client.measureLatency({ timeoutMs: 10_000 }); + await vi.advanceTimersByTimeAsync(1_000); + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(5_000); // the first window closes silent at 16s + expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s"]); + // The heartbeat's pong lands at 17s, inside the second window and before the 20s deadline. + await vi.advanceTimersByTimeAsync(1_000); + await expect(measurement).resolves.toBe(7000); + await vi.advanceTimersByTimeAsync(10_000); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(daemon.closesFromClient()).toEqual([]); + expect(attempts()).toBe(1); + expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s", "27s"]); +}); + +// Fake timers cannot suspend: a suspension is modelled as the wall clock jumping forward while +// the pending window timer stays queued, then firing late on resume. +async function suspendFor(ms: number): Promise { + vi.setSystemTime(Date.now() + ms); +} + +test("a foreground window that spanned a second suspension is discarded when the timer fires first", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-timer", [daemon]); + const connection = client.connect(); + daemon.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(5_000); // window 1 silent, window 2 outstanding + expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]); + await suspendFor(60_000); + daemon.daemonAnswersPingsAfter("fast"); // the socket is healthy on resume + await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late + client.ensureConnected({ verify: true }); // then AppState lands + await vi.advanceTimersByTimeAsync(1_000); + expect(daemon.closesFromClient()).toEqual([]); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(attempts()).toBe(1); +}); + +test("a foreground window that spanned a second suspension is discarded when AppState fires first", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-appstate", [daemon]); + const connection = client.connect(); + daemon.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(5_000); + expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]); + await suspendFor(60_000); + daemon.daemonAnswersPingsAfter("fast"); + client.ensureConnected({ verify: true }); // AppState lands before the queued timer + await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late + await vi.advanceTimersByTimeAsync(1_000); + expect(daemon.closesFromClient()).toEqual([]); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(attempts()).toBe(1); +}); + +test("a dead socket after a second suspension mid-cycle still gets two fresh windows", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-dead", [ + first, + second, + ]); + const connection = client.connect(); + first.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(5_000); + first.receiveChunk(true); // a frame before the second suspension is not evidence after it + await suspendFor(60_000); + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(5_000); // the late timer discards window 2 and starts over + expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]); + await vi.advanceTimersByTimeAsync(5_000); + expect(first.closesFromClient()).toEqual([]); + expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s", "15s"]); + await vi.advanceTimersByTimeAsync(5_000); + expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); + expect(attempts()).toBe(2); +}); + +test("repeated foreground events share one cycle and do not restart its windows", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("repeated-foreground", [first, second]); + const connection = client.connect(); + first.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + for (let elapsedSeconds = 1; elapsedSeconds <= 9; elapsedSeconds++) { + await vi.advanceTimersByTimeAsync(1_000); + client.ensureConnected({ verify: true }); + expect(first.closesFromClient()).toEqual([]); + } + expect(first.pingTimestamps()).toEqual(["0s", "5s"]); + await vi.advanceTimersByTimeAsync(1_000); + expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); + expect(attempts()).toBe(2); + second.openConnection(); + expect(client.getConnectionState()).toEqual({ status: "connected" }); +}); + +test("a late pong from the replaced socket does not count for the replacement", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + second.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("late-pong", [first, second]); + const connection = client.connect(); + first.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(10_000); + expect(attempts()).toBe(2); + second.openConnection(); + client.ensureConnected({ verify: true }); + expect(second.pingTimestamps()).toEqual(["10s"]); + await vi.advanceTimersByTimeAsync(2_000); + first.daemonSendsPong(); + await vi.advanceTimersByTimeAsync(8_000); + expect(second.pingTimestamps()).toEqual(["10s", "15s"]); + expect(second.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); + expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); +}); + +test("closing the client in the middle of a foreground cycle stops it", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("close-mid-cycle", [first, second]); + const connection = client.connect(); + first.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(5_000); + expect(first.pingTimestamps()).toEqual(["0s", "5s"]); + await client.close(); + await vi.advanceTimersByTimeAsync(60_000); + expect(first.pingTimestamps()).toEqual(["0s", "5s"]); + expect(first.closesFromClient()).toEqual([{ code: 1000, reason: "Client closed" }]); + expect(attempts()).toBe(1); + expect(client.getConnectionState()).toEqual({ status: "disposed" }); +}); + +test("a foreground teardown with reconnect disabled reports the timeout and stays down", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("no-reconnect", [daemon], { + reconnect: { enabled: false }, + }); + const connection = client.connect(); + daemon.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(10_000); + expect(client.getConnectionState()).toEqual({ + status: "disconnected", + reason: "Liveness check timed out (5000ms)", + }); + await vi.advanceTimersByTimeAsync(10_000); + expect(attempts()).toBe(1); +}); + +test("the immediate reconnect after a foreground teardown backs off normally if it fails", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + const third = new FakeDaemon(); + first.daemonGoesSilent(); + const { client, attempts } = connectThroughReplacements("no-storm", [first, second, third]); + const connection = client.connect(); + first.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(10_000); + expect(attempts()).toBe(2); + second.daemonClosesWith("connection refused"); + await vi.advanceTimersByTimeAsync(1_499); + expect(attempts()).toBe(2); + await vi.advanceTimersByTimeAsync(1); + expect(attempts()).toBe(3); + third.openConnection(); + expect(client.getConnectionState()).toEqual({ status: "connected" }); +}); + test("an obsolete heartbeat cannot close a replacement connection", async () => { useHeartbeatClock(); const first = new FakeDaemon(); @@ -2887,6 +3293,18 @@ test("a heartbeat sharing a latency measurement still saves the RTT", async () = expect(session.lastLivenessRttMs()).toBe(2000); }); +test("a measurement that joins an in-flight ping reports that ping's round trip", async () => { + useHeartbeatClock(); + const session = new DaemonClientSession(); + session.daemonAnswersPingsAfter("2s"); + await session.connect(); + await session.advance(11_000); // the heartbeat ping sent at 10s is still outstanding + const measurement = session.measureLatency({ timeoutMs: 5_000 }); + await session.advance(1_000); + await expect(measurement).resolves.toBe(2000); + expect(session.lastLivenessRttMs()).toBe(2000); +}); + test("reports the round-trip time of the last successful heartbeat", async () => { useHeartbeatClock(); const session = new DaemonClientSession(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 903dd0803e..454dc998e0 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,9 @@ +import { + DiffStreamRouter, + DiffSubscriptionError, + type DiffCompare, + type DiffState, +} from "./diff/index.js"; import { ChunkedTransport, ChunkedTransportProtocolError, @@ -1149,6 +1155,12 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; const DEFAULT_LIVENESS_TIMEOUT_MS = 5000; const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000; const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15_000; +// Window of each check after the app returns to the foreground. A socket that silently died +// while the app was suspended stays OPEN and accepts sends, so only inbound frames prove it. +const FOREGROUND_LIVENESS_TIMEOUT_MS = 5_000; +// A foreground window whose timer fires this much later than scheduled (wall clock) spanned +// another suspension or a blocked JS thread, so its silence is no evidence either way. +const SUSPENDED_WINDOW_SLACK_MS = 2_000; const LIVENESS_FAILURE_RECONNECT_THRESHOLD = 2; /** Default timeout for waiting for connection before sending queued messages */ @@ -1311,13 +1323,27 @@ interface PingProbe { reject: (error: Error) => void; timeoutHandle: ReturnType; startedAt: number; + // Wall clock, which keeps running while the OS has JS suspended; perfNow may not. + startedAtWallMs: number; receivedActivity: boolean; - // Whether a timeout on this ping should be recorded as a liveness failure. Only the - // heartbeat sets this; a latency measurement never drives teardown, even when a - // heartbeat tick shares (dedupes onto) an in-flight measurement ping. - drivesLivenessFailure: boolean; + role: PingProbeRole; } +// A latency measurement waits for the next pong, not for a particular probe: the liveness +// cycle may replace the probe the measurement joined, and a pong that answers an expired window +// still proves the socket. It settles on a pong, a connection failure, or its own deadline. +interface PongWaiter { + startedAt: number; + resolve: (rttMs: number) => void; + reject: (error: Error) => void; +} + +// Who started the probe. A "latency" probe never drives teardown, even when a heartbeat +// tick shares (dedupes onto) it. A "heartbeat" probe runs the regular cadence. A +// "foreground" probe belongs to the verification cycle that ensureConnected({ verify }) +// runs after the app resumes: short window, immediate second check, reconnect at once. +type PingProbeRole = "latency" | "heartbeat" | "foreground"; + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. export function supportsUsageReports(features: ServerInfoStatusPayload["features"]): boolean { return features?.usageSources === true || features?.providerUsageList === true; @@ -1426,6 +1452,20 @@ export class DaemonClient { private connectionFailure: Error | undefined; private authFailureReasonValue: DaemonAuthFailureReason | null = null; private connectionState: ConnectionState = { status: "idle" }; + private readonly diffStreams = new DiffStreamRouter({ + modern: (compare) => + this.observe( + "diff.subscribe.response", + { type: "diff.subscribe.request", ...compare }, + { + validateSnapshot: (payload) => { + if (payload.error) throw new DiffSubscriptionError(payload.error); + }, + }, + ), + legacy: (cwd, compare) => this.observeCheckoutDiff(cwd, compare), + failed: (error) => this.logger.error({ err: error }, "Diff observer failed"), + }); private readonly terminalStreams = new TerminalStreamRouter(); private pendingBinaryFileReads = new Map(); private activeBinaryFileTransfers = new Map(); @@ -1455,6 +1495,7 @@ export class DaemonClient { private runtimeMetricsInterval: ReturnType | null = null; private runtimeMetrics: DaemonClientRuntimeMetrics | null = null; private pingProbe: PingProbe | null = null; + private readonly pongWaiters = new Set(); private livenessHeartbeatTimer: ReturnType | null = null; private lastLivenessRttMs: number | null = null; private consecutiveLivenessFailures = 0; @@ -1775,10 +1816,12 @@ export class DaemonClient { this.disposeTransport(1000, "Client closed"); this.providerSnapshotUpdates.clear(); this.clearWaiters(new Error("Daemon client closed")); + await this.diffStreams.close(); await this.owned.close(); this.creations.close(); this.rejectPendingSendQueue(new Error("Daemon client closed")); this.rejectPingProbe(new Error("Daemon client closed")); + this.rejectPongWaiters(new Error("Daemon client closed")); this.terminalStreams.clearSlots(); this.lastServerInfoMessage = null; if (this.runtimeMetricsInterval) { @@ -1801,7 +1844,7 @@ export class DaemonClient { this.shouldReconnect = true; } if (this.connectionState.status === "connected") { - if (options?.verify) this.tickLivenessHeartbeat(); + if (options?.verify) this.verifyConnectionOnForeground(); return; } if (this.connectionState.status === "connecting") return; @@ -2548,43 +2591,41 @@ export class DaemonClient { measureLatency(params?: { timeoutMs?: number }): Promise { const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS); - let deadline: ReturnType; - const timeout = new Promise((_resolve, reject) => { - deadline = setTimeout(() => reject(new PingTimeoutError(timeoutMs)), timeoutMs); - }); - return Promise.race([ - this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: false }), - timeout, - ]) - .catch((error) => { - throw toTimeoutError(error, "Latency measurement", timeoutMs); - }) - .finally(() => clearTimeout(deadline)); - } - - private async livenessPing(params?: { timeoutMs?: number }): Promise { - const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS); - try { - const rttMs = await this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: true }); - this.lastLivenessRttMs = rttMs; - return rttMs; - } catch (error) { - throw toTimeoutError(error, "Liveness check", timeoutMs); - } - } - - private sendPingAwaitRtt(params: { - timeoutMs: number; - drivesLivenessFailure: boolean; - }): Promise { if (this.connectionState.status !== "connected" || !this.transport) { return Promise.reject( new Error(`Transport not connected (status: ${this.connectionState.status})`), ); } + const waiter: PongWaiter = { startedAt: perfNow(), resolve: () => {}, reject: () => {} }; + const pong = new Promise((resolve, reject) => { + waiter.resolve = resolve; + waiter.reject = reject; + }); + let deadline: ReturnType; + const timeout = new Promise((_resolve, reject) => { + deadline = setTimeout(() => reject(new PingTimeoutError(timeoutMs)), timeoutMs); + }); + this.pongWaiters.add(waiter); + // The probe only puts a ping on the wire; its own outcome is the heartbeat's concern. A + // measurement that joins an in-flight ping reports that ping's round trip, so the clock + // starts at the send, not at the join. + const probe = this.ensurePingProbe({ timeoutMs, role: "latency" }); + void probe.promise.catch(() => {}); + waiter.startedAt = probe.startedAt; + return Promise.race([pong, timeout]) + .catch((error) => { + throw toTimeoutError(error, "Latency measurement", timeoutMs); + }) + .finally(() => { + clearTimeout(deadline); + this.pongWaiters.delete(waiter); + }); + } + // One ping is outstanding at a time; a caller that finds one shares it. + private ensurePingProbe(params: { timeoutMs: number; role: PingProbeRole }): PingProbe { if (this.pingProbe) { - return this.pingProbe.promise; + return this.pingProbe; } const startedAt = perfNow(); @@ -2600,10 +2641,20 @@ export class DaemonClient { this.pingProbe = null; const error = new PingTimeoutError(timeoutMs); probe.reject(error); + if ( + probe.role === "foreground" && + Date.now() - probe.startedAtWallMs > timeoutMs + SUSPENDED_WINDOW_SLACK_MS + ) { + // The app was suspended again inside this window. Discard it, any frame it saw and + // the misses before it; the heartbeat tick that owns this probe starts the cycle over. + probe.receivedActivity = false; + this.consecutiveLivenessFailures = 0; + return; + } // Keep the deadline fixed so subsequent pings renew the daemon socket lease // during a long download. Progress proves life even when its pong is queued. - if (probe.drivesLivenessFailure && !probe.receivedActivity) { - this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs)); + if (probe.role !== "latency" && !probe.receivedActivity) { + this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs), probe.role); } }; const probe: PingProbe = { @@ -2613,7 +2664,8 @@ export class DaemonClient { timeoutHandle: setTimeout(expire, timeoutMs), receivedActivity: false, startedAt, - drivesLivenessFailure: params.drivesLivenessFailure, + startedAtWallMs: Date.now(), + role: params.role, }; this.pingProbe = probe; @@ -2621,7 +2673,8 @@ export class DaemonClient { if (this.pingProbe !== probe) return; const sendError = error instanceof Error ? error : new Error(String(error)); this.rejectPingProbe(sendError); - if (probe.drivesLivenessFailure) this.recordLivenessFailure(sendError); + this.rejectPongWaiters(sendError); + if (probe.role !== "latency") this.recordLivenessFailure(sendError, probe.role); }; try { void this.sendJsonMessage("ping", "ping", { type: "ping" }).catch(rejectSend); @@ -2629,7 +2682,7 @@ export class DaemonClient { rejectSend(error); } - return promise; + return probe; } private startLivenessHeartbeat(): void { @@ -2656,14 +2709,55 @@ export class DaemonClient { }, LIVENESS_HEARTBEAT_INTERVAL_MS); } - private tickLivenessHeartbeat(): void { + /** + * The app came back to the foreground. Start a fresh verification cycle: two + * FOREGROUND_LIVENESS_TIMEOUT_MS windows back to back, then teardown and an immediate + * reconnect. Misses recorded before the app was suspended do not count, and a heartbeat + * probe that straddled the suspension measured the suspension, not the socket, so it is + * dropped without a miss. Latency measurements wait for the next pong, so replacing the + * probe they joined does not fail them. + */ + private verifyConnectionOnForeground(): void { + // A cycle already running keeps its deadline and its window count. + if (this.pingProbe?.role === "foreground") return; + if (this.pingProbe?.role === "heartbeat") { + this.rejectPingProbe(new Error("Heartbeat superseded by foreground check")); + } + // A shared latency probe keeps its deadline, but only frames that arrive from now on count + // for this check; a latency probe never reads this flag for itself. + if (this.pingProbe) this.pingProbe.receivedActivity = false; + this.consecutiveLivenessFailures = 0; + this.tickLivenessHeartbeat("foreground"); + } + + private tickLivenessHeartbeat(check: "heartbeat" | "foreground" = "heartbeat"): void { this.stopLivenessHeartbeat(); const transport = this.transport; - const tick = this.livenessPing({ timeoutMs: LIVENESS_HEARTBEAT_TIMEOUT_MS }); - void tick - .catch(() => {}) - .finally(() => { - if (this.transport === transport) this.scheduleNextLivenessHeartbeat(); + if (this.connectionState.status !== "connected" || !transport) return; + const probe = this.ensurePingProbe({ + timeoutMs: + check === "foreground" ? FOREGROUND_LIVENESS_TIMEOUT_MS : LIVENESS_HEARTBEAT_TIMEOUT_MS, + role: check, + }); + void probe.promise + .then( + (rttMs) => { + this.lastLivenessRttMs = rttMs; + return true; + }, + () => probe.receivedActivity, + ) + .then((alive) => { + // A replaced transport, or a probe that superseded this one, owns the next step. + if (this.transport !== transport || this.pingProbe) return; + if (!alive && check === "foreground") { + // Zero inbound frames in a foreground window: check again now. The second silent + // window reaches the threshold inside recordLivenessFailure. + this.tickLivenessHeartbeat("foreground"); + return; + } + this.scheduleNextLivenessHeartbeat(); + return; }); } @@ -2674,7 +2768,12 @@ export class DaemonClient { private observe( responseType: T, message: { type: SessionInboundMessage["type"] } & Record, - options?: { requestId?: string; timeout?: number; signal?: AbortSignal }, + options?: { + requestId?: string; + timeout?: number; + signal?: AbortSignal; + validateSnapshot?: (payload: CorrelatedResponsePayload) => void; + }, ): OwnedSubscription> { let resetSource = false; return this.owned.observeRequest>( @@ -2700,12 +2799,16 @@ export class DaemonClient { requestType: message.type, error: payload.error, }); + options?.validateSnapshot?.(payload); accept(payload); return payload; }, }); } catch (error) { - resetSource = !(error instanceof DaemonRpcError) && this.isConnected; + resetSource = + !(error instanceof DaemonRpcError) && + !(error instanceof DiffSubscriptionError) && + this.isConnected; throw error; } }, @@ -4856,6 +4959,26 @@ export class DaemonClient { }); } + observeDiff(compare: DiffCompare): OwnedSubscription { + return this.diffStreams.observe(compare); + } + + async readDiff(compare: DiffCompare): Promise { + const observation = this.observeDiff(compare); + try { + return await new Promise((resolve) => { + observation.subscribe({ + snapshot: (state) => { + if (state.phase !== "loading") resolve(state); + }, + update: () => {}, + }); + }); + } finally { + await observation.release(); + } + } + observeCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean }, @@ -7180,6 +7303,7 @@ export class DaemonClient { this.chunkedTransport?.close(); this.chunkedTransport = null; this.owned.disconnected(); + this.diffStreams.disconnected(); this.providerSnapshotUpdates.clear(); this.stopLivenessHeartbeat(); this.cleanupTransport(); @@ -7312,6 +7436,7 @@ export class DaemonClient { messageType: "pong", }); this.resolvePingProbe(); + this.resolvePongWaiters(); this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs); return; } @@ -7370,6 +7495,10 @@ export class DaemonClient { for (const handler of this.hostFrameListeners) handler(hostFrame); return true; } + if (rawBytes[0] >= 0x40 && rawBytes[0] <= 0x4f) { + this.diffStreams.receive(rawBytes); + return true; + } if (isPluginFrameOpcode(rawBytes[0])) { this.handlePluginFrame(rawBytes); return true; @@ -7557,6 +7686,8 @@ export class DaemonClient { reason?: string; event?: string; reasonCode?: string; + // Attempt now instead of after the backoff delay. Failures of that attempt back off as usual. + immediate?: boolean; }): void { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); @@ -7570,6 +7701,7 @@ export class DaemonClient { } this.owned.disconnected(); + this.diffStreams.disconnected(); this.providerSnapshotUpdates.clear(); // Clear all pending waiters and queued sends since the connection was lost @@ -7586,6 +7718,7 @@ export class DaemonClient { this.clearWaiters(connectionError, true); this.rejectPendingSendQueue(connectionError); this.rejectPingProbe(connectionError); + this.rejectPongWaiters(connectionError); this.terminalStreams.clearSlots(); this.lastServerInfoMessage = null; @@ -7605,6 +7738,10 @@ export class DaemonClient { return; } + if (input?.immediate) { + void this.attemptConnect(); + return; + } this.armReconnectTimer(); } @@ -7679,7 +7816,18 @@ export class DaemonClient { probe.reject(error); } - private recordLivenessFailure(error: Error): void { + private resolvePongWaiters(): void { + const now = perfNow(); + for (const waiter of this.pongWaiters) waiter.resolve(now - waiter.startedAt); + this.pongWaiters.clear(); + } + + private rejectPongWaiters(error: Error): void { + for (const waiter of this.pongWaiters) waiter.reject(error); + this.pongWaiters.clear(); + } + + private recordLivenessFailure(error: Error, role: PingProbeRole): void { this.consecutiveLivenessFailures += 1; if (this.consecutiveLivenessFailures < LIVENESS_FAILURE_RECONNECT_THRESHOLD) { return; @@ -7691,6 +7839,8 @@ export class DaemonClient { reason: error.message, event: "LIVENESS_TIMEOUT", reasonCode: "liveness_timeout", + // The foreground cycle already waited two windows; the user is looking at the app. + immediate: role === "foreground", }); } @@ -7730,6 +7880,7 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.startLivenessHeartbeat(); + this.diffStreams.connected(serverInfo.features?.diffSubscription === true); this.owned.restore(serverInfo, { ...DEFAULT_CLIENT_CAPABILITIES, ...this.config.capabilities, @@ -8007,3 +8158,5 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon cwd: merged.cwd, }; } + +export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js"; diff --git a/packages/client/src/diff/client.test.ts b/packages/client/src/diff/client.test.ts new file mode 100644 index 0000000000..04f06d227f --- /dev/null +++ b/packages/client/src/diff/client.test.ts @@ -0,0 +1,210 @@ +import { expect, test } from "vitest"; +import { DaemonClient, type DaemonTransport } from "../daemon-client.js"; +import { + encodeDiffFrame, + DiffOpcode, + type DiffFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import type { DiffState } from "./index.js"; +function socket() { + let message: (data: unknown, binary?: boolean) => void = () => {}; + let open: () => void = () => {}; + let close: (event?: unknown) => void = () => {}; + const sent: Record[] = []; + const transport: DaemonTransport = { + send: (data) => { + if (typeof data === "string") { + const value = JSON.parse(data); + if (value.type === "session") { + sent.push(value.message); + if (value.message.type === "subscription.release.request") + queueMicrotask(() => + receive({ + type: "subscription.release.response", + payload: { + requestId: value.message.requestId, + subscriptionId: value.message.subscriptionId, + released: true, + }, + }), + ); + } + } + }, + close: () => {}, + onMessage: (handler) => { + message = handler; + return () => {}; + }, + onOpen: (handler) => { + open = handler; + return () => {}; + }, + onClose: (handler) => { + close = handler; + return () => {}; + }, + onError: () => () => {}, + }; + const receive = (value: unknown) => + message(JSON.stringify({ type: "session", message: value }), false); + return { + transport, + sent, + receive, + raw: (bytes: Uint8Array) => message(bytes, true), + frame: (frame: DiffFrame) => message(encodeDiffFrame(frame), true), + disconnect: () => close({ code: 1001 }), + open: (modern = true) => { + open(); + receive({ + type: "status", + payload: { + status: "server_info", + serverId: "srv_test", + hostname: null, + version: null, + features: { ownedSubscriptions: true, diffSubscription: modern, checkoutDiffAll: true }, + }, + }); + }, + }; +} +const compare = { + cwd: "/repo", + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, +} as const; +const begin: DiffFrame = { + opcode: DiffOpcode.Begin, + slot: 3, + revision: 1, + baseRevision: 0, + baseSha: "a", + headSha: "b", + mergeBaseSha: "", + entries: [], +}; +const end: DiffFrame = { + opcode: DiffOpcode.End, + slot: 3, + revision: 1, + diffTooLarge: false, + omitted: [], + error: null, +}; +async function setup(modern = true) { + const wire = socket(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "diff-test", + transportFactory: () => wire.transport, + reconnect: { enabled: false }, + }); + const connecting = client.connect(); + wire.open(modern); + await connecting; + return { wire, client }; +} +function acknowledge(wire: ReturnType, ordinal = 1) { + const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request"); + wire.receive({ + type: "diff.subscribe.response", + payload: { + requestId: request?.requestId, + subscriptionId: `diff-${ordinal}`, + slot: 3, + error: null, + }, + }); +} +test("attaches binary routing during handshake and resubscribes with a full state on reconnect", async () => { + const { wire, client } = await setup(); + try { + const states: DiffState[] = []; + const observation = client.observeDiff(compare); + observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} }); + acknowledge(wire); + wire.frame(begin); + wire.frame(end); + expect(states.map((state) => state.phase)).toEqual(["loading", "ready"]); + wire.disconnect(); + const connected = client.connect(); + wire.open(); + await connected; + acknowledge(wire, 2); + wire.frame(begin); + wire.frame(end); + expect(states.map((state) => state.phase)).toEqual(["loading", "ready", "loading", "ready"]); + await observation.release(); + } finally { + await client.close(); + } +}); +test("an unknown version fails only its subscription and readDiff resolves typed subscribe errors", async () => { + const { wire, client } = await setup(); + try { + const observation = client.observeDiff(compare); + const states: DiffState[] = []; + observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} }); + acknowledge(wire); + const malformed = { ...begin, slot: 3 }; + const bytes = encodeDiffFrame(malformed); + bytes[2] = 255; + wire.raw(bytes); + expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "UNKNOWN" } }); + const next = client.readDiff(compare); + const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request"); + wire.receive({ + type: "diff.subscribe.response", + payload: { + requestId: request?.requestId, + subscriptionId: "", + slot: 0, + error: { code: "INVALID_COMPARE", message: "invalid" }, + }, + }); + expect(await next).toMatchObject({ phase: "error", error: { code: "INVALID_COMPARE" } }); + expect(client.isConnected).toBe(true); + await observation.release(); + } finally { + await client.close(); + } +}); +test.each(["uncommitted", "base", "all"] as const)( + "old hosts serve the %s comparison through the tagged compatibility route", + async (mode) => { + const { wire, client } = await setup(false); + try { + const comparison = + mode === "uncommitted" + ? compare + : { + ...compare, + base: { kind: "rev", rev: "refs/tags/v1" } as const, + head: mode === "base" ? ({ kind: "rev", rev: "HEAD" } as const) : compare.head, + mergeBase: true, + }; + const result = client.readDiff(comparison); + const request = wire.sent.find((value) => value.type === "subscribe_checkout_diff_request"); + expect(request?.compare).toMatchObject({ mode }); + wire.receive({ + type: "subscribe_checkout_diff_response", + payload: { + requestId: request?.requestId, + subscriptionId: "legacy", + cwd: "/repo", + files: [], + error: null, + }, + }); + wire.receive({ + type: "checkout_diff_update", + payload: { subscriptionId: "legacy", cwd: "/repo", files: [], error: null }, + }); + expect(await result).toMatchObject({ phase: "ready", files: [] }); + } finally { + await client.close(); + } + }, +); diff --git a/packages/client/src/diff/index.ts b/packages/client/src/diff/index.ts new file mode 100644 index 0000000000..31fe4394a6 --- /dev/null +++ b/packages/client/src/diff/index.ts @@ -0,0 +1,246 @@ +import { + decodeDiffFrame, + type DiffEntry as WireEntry, + type DiffBegin, +} from "@getpaseo/protocol/binary-frames/index"; +import type { + DiffSubscribeRequest, + SubscribeCheckoutDiffRequest, + DiffError, + DiffSubscribeResponse, + SubscribeCheckoutDiffResponse, + ParsedDiffFile, +} from "@getpaseo/protocol/messages"; +import type { OwnedSubscription, SubscriptionObserver } from "../connection/index.js"; +import { DiffStream } from "./stream.js"; +export type { DiffEndpoint } from "@getpaseo/protocol/messages"; +export type DiffCompare = Omit; + +export type DiffEntry = Omit; +export type ResolvedShas = Pick; + +export type DiffState = + | { phase: "loading"; revision: number; entries: DiffEntry[] } + | { + phase: "ready"; + revision: number; + resolved: ResolvedShas; + diffTooLarge: boolean; + files: ParsedDiffFile[]; + } + | { phase: "error"; revision: number; error: DiffError }; +type LegacyCompare = SubscribeCheckoutDiffRequest["compare"]; + +interface Port { + modern(compare: DiffCompare): OwnedSubscription; + legacy( + cwd: string, + compare: LegacyCompare, + ): OwnedSubscription; + failed(error: unknown): void; +} +export class DiffSubscriptionError extends Error { + constructor(readonly detail: DiffError) { + super(detail.message); + } +} +interface Registration { + start(modern: boolean): void; + disconnected(): void; + release(): Promise; +} +/** Capability selection, slot ownership and reconnect belong to this domain. */ +export class DiffStreamRouter { + private registrations = new Set(); + private slots = new Map(); + private mode: boolean | null = null; + constructor(private port: Port) {} + connected(modern: boolean): void { + this.mode = modern; + for (const registration of this.registrations) registration.start(modern); + } + disconnected(): void { + this.mode = null; + this.slots.clear(); + for (const registration of this.registrations) registration.disconnected(); + } + async close(): Promise { + await Promise.all([...this.registrations].map((registration) => registration.release())); + } + receive(bytes: Uint8Array): void { + const owner = this.slots.get(bytes[1]); + owner?.receive(bytes); + } + private releaseBackend(backend: OwnedSubscription | null): void { + void backend?.release().catch(this.port.failed); + } + observe(compare: DiffCompare): OwnedSubscription { + let backend: OwnedSubscription | null = null; + let state: (DiffState & { subscriptionId: string }) | null = null; + let slot: number | null = null; + let released = false; + let generation = 0; + let stream: DiffStream; + const observers = new Set>(); + let resolve!: (state: DiffState & { subscriptionId: string }) => void; + const ready = new Promise((accept) => { + resolve = accept; + }); + const publish = (value: DiffState) => { + if (released) return; + state = { ...value, subscriptionId: backend?.subscriptionId ?? "" }; + resolve(state); + for (const observer of observers) { + try { + observer.snapshot(state); + } catch (error) { + this.port.failed(error); + } + } + }; + const detach = () => { + if (slot !== null) this.slots.delete(slot); + slot = null; + }; + const fail = (error: unknown) => { + detach(); + publish({ + phase: "error", + revision: 0, + error: + error instanceof DiffSubscriptionError + ? error.detail + : { code: "UNKNOWN", message: error instanceof Error ? error.message : String(error) }, + }); + this.releaseBackend(backend); + }; + const registration: Registration = { + start: (modern) => { + if (released || backend) return; + const current = ++generation; + const failed = (error: unknown) => { + if (current === generation) fail(error); + }; + stream = new DiffStream(publish); + if (modern) { + const observation = this.port.modern(compare); + backend = observation; + observation.subscribe({ + snapshot: (payload) => { + if (current !== generation) return; + detach(); + if (payload.error) { + fail(new DiffSubscriptionError(payload.error)); + return; + } + slot = payload.slot; + const owner = { + receive: (bytes: Uint8Array) => { + try { + stream.receive(decodeDiffFrame(bytes)); + } catch (error) { + fail(error); + } + }, + }; + this.slots.set(slot, owner); + }, + update: () => {}, + error: failed, + }); + void observation.ready.catch(failed); + } else { + // COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06. + const legacy = legacyCompare(compare); + if (!legacy) { + fail( + new DiffSubscriptionError({ + code: "INVALID_COMPARE", + message: "Update the host to use this comparison", + }), + ); + return; + } + const observation = this.port.legacy(compare.cwd, legacy); + backend = observation; + const apply = ( + payload: Pick< + SubscribeCheckoutDiffResponse["payload"], + "files" | "error" | "diffTooLarge" + >, + ) => { + if (current !== generation) return; + if (payload.error) + publish({ + phase: "error", + revision: 0, + error: { + code: payload.error.code === "NOT_GIT_REPO" ? "NOT_GIT_REPO" : "UNKNOWN", + message: payload.error.message, + }, + }); + else + publish({ + phase: "ready", + revision: (state?.revision ?? 0) + 1, + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + diffTooLarge: payload.diffTooLarge === true, + files: payload.files, + }); + }; + observation.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "checkout_diff_update") apply(message.payload); + }, + error: failed, + }); + void observation.ready.catch(failed); + } + }, + disconnected: () => { + generation++; + detach(); + state = null; + const old = backend; + backend = null; + void old?.release().catch(this.port.failed); + }, + release: async () => { + if (released) return; + released = true; + generation++; + detach(); + this.registrations.delete(registration); + await backend?.release(); + observers.clear(); + }, + }; + this.registrations.add(registration); + if (this.mode !== null) registration.start(this.mode); + return { + get subscriptionId() { + return backend?.subscriptionId ?? null; + }, + ready, + subscribe: (observer) => { + observers.add(observer); + if (state) observer.snapshot(state); + return () => observers.delete(observer); + }, + release: () => registration.release(), + }; + } +} +function legacyCompare(compare: DiffCompare): LegacyCompare | null { + if (compare.highlight === false) return null; + const { base, head } = compare; + if (base.kind !== "rev") return null; + if (base.rev === "HEAD" && head.kind === "worktree") + return { mode: "uncommitted", ignoreWhitespace: compare.ignoreWhitespace }; + if (compare.mergeBase && head.kind === "rev" && head.rev === "HEAD") + return { mode: "base", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace }; + if (compare.mergeBase && head.kind === "worktree") + return { mode: "all", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace }; + return null; +} diff --git a/packages/client/src/diff/stream.test.ts b/packages/client/src/diff/stream.test.ts new file mode 100644 index 0000000000..b740469029 --- /dev/null +++ b/packages/client/src/diff/stream.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "vitest"; +import { DiffStream } from "./stream.js"; +import { DiffOpcode, type DiffBegin } from "@getpaseo/protocol/binary-frames/index"; +import type { DiffState } from "./index.js"; +const entry = { + path: "a.ts", + oldPath: null, + change: "modified" as const, + content: "text" as const, + additions: 1, + deletions: 0, + body: true, +}; +const begin = (revision: number, baseRevision = 0): DiffBegin => ({ + opcode: DiffOpcode.Begin, + slot: 0, + revision, + baseRevision, + baseSha: "a", + headSha: "b", + mergeBaseSha: "", + entries: [entry], +}); +const end = (revision: number) => + ({ + opcode: DiffOpcode.End, + slot: 0, + revision, + diffTooLarge: false, + omitted: [], + error: null, + }) as const; +test("publishes the initial tree, commits on End and buffers refreshes with stable equal files", () => { + const states: DiffState[] = []; + const stream = new DiffStream((state) => states.push(state)); + stream.receive(begin(1)); + expect(states[0]).toMatchObject({ phase: "loading", entries: [{ path: "a.ts" }] }); + stream.receive({ + opcode: DiffOpcode.Bodies, + slot: 0, + revision: 1, + files: [{ path: "a.ts", hunks: [] }], + }); + expect(states).toHaveLength(1); + stream.receive(end(1)); + const ready = states[1]; + if (ready.phase !== "ready") throw new Error("Expected ready"); + stream.receive(begin(2, 1)); + stream.receive({ + opcode: DiffOpcode.Bodies, + slot: 0, + revision: 2, + files: [{ path: "a.ts", hunks: [] }], + }); + expect(states).toHaveLength(2); + stream.receive(end(2)); + const refreshed = states[2]; + if (refreshed.phase !== "ready") throw new Error("Expected ready"); + expect(refreshed.files[0]).toBe(ready.files[0]); +}); +test("superseded and stale frames cannot leave the client loading; End is authoritative", () => { + const states: DiffState[] = []; + const stream = new DiffStream((s) => states.push(s)); + stream.receive(begin(1)); + stream.receive(begin(2)); + stream.receive(end(1)); + expect(() => stream.receive(end(2))).toThrow(/Missing/); + stream.receive(begin(3)); + stream.receive({ ...end(3), error: { code: "REF_NOT_FOUND", message: "gone" } }); + expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "REF_NOT_FOUND" } }); + stream.receive({ ...begin(4), entries: [] }); + stream.receive(end(4)); + expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] }); +}); +test("unchanged entries retain hunks, omissions override cache, removed paths disappear", () => { + const states: DiffState[] = []; + const stream = new DiffStream((s) => states.push(s)); + stream.receive(begin(1)); + stream.receive({ ...end(1), omitted: ["a.ts"], diffTooLarge: true }); + stream.receive({ ...begin(2, 1), entries: [{ ...entry, body: false }] }); + stream.receive(end(2)); + expect(states.at(-1)).toMatchObject({ phase: "ready", files: [{ status: "too_large" }] }); + stream.receive({ ...begin(3, 2), entries: [] }); + stream.receive(end(3)); + expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] }); +}); diff --git a/packages/client/src/diff/stream.ts b/packages/client/src/diff/stream.ts new file mode 100644 index 0000000000..ac55f681ec --- /dev/null +++ b/packages/client/src/diff/stream.ts @@ -0,0 +1,94 @@ +import { + DiffOpcode, + materializeDiffFile, + type DiffBegin, + type DiffBody, + type DiffFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import type { DiffState } from "./index.js"; + +/** Owns revision assembly. End, rather than a local body count, commits the snapshot. */ +export class DiffStream { + private pending: { begin: DiffBegin; bodies: Map } | null = null; + private committed: Extract | null = null; + constructor(private publish: (state: DiffState) => void) {} + receive(frame: DiffFrame): void { + if (frame.opcode === DiffOpcode.Begin) { + if (frame.baseRevision !== 0 && frame.baseRevision !== this.committed?.revision) + throw new Error("Unknown diff base revision"); + if (new Set(frame.entries.map((entry) => entry.path)).size !== frame.entries.length) + throw new Error("Duplicate diff entry"); + this.pending = { begin: frame, bodies: new Map() }; + if (!this.committed) + this.publish({ + phase: "loading", + revision: frame.revision, + entries: frame.entries.map(({ body: _body, ...entry }) => entry), + }); + return; + } + const pending = this.pending; + if (!pending || frame.revision !== pending.begin.revision) return; + if (frame.opcode === DiffOpcode.Bodies) { + for (const body of frame.files) { + if (pending.bodies.has(body.path)) throw new Error("Duplicate diff body"); + const entry = pending.begin.entries.find((candidate) => candidate.path === body.path); + if (!entry?.body) throw new Error("Unexpected diff body"); + pending.bodies.set(body.path, body); + } + return; + } + this.pending = null; + if (frame.error) { + this.committed = null; + this.publish({ phase: "error", revision: frame.revision, error: frame.error }); + return; + } + const previous = new Map(this.committed?.files.map((file) => [file.path, file])); + const omitted = new Set(frame.omitted); + for (const path of omitted) + if (!pending.begin.entries.some((entry) => entry.path === path)) + throw new Error("Unknown omitted diff path"); + const files = pending.begin.entries.map((entry) => { + const old = previous.get(entry.path); + const body = pending.bodies.get(entry.path); + if (entry.content === "text" && !omitted.has(entry.path) && entry.body && !body) + throw new Error(`Missing diff body: ${entry.path}`); + if ( + entry.content === "text" && + !entry.body && + !omitted.has(entry.path) && + (!old || pending.begin.baseRevision === 0) + ) + throw new Error(`Missing inherited diff: ${entry.path}`); + let file = materializeDiffFile(entry, body ?? null, omitted.has(entry.path)); + if (entry.content === "text" && !entry.body && !omitted.has(entry.path) && old) + file = { ...file, hunks: old.hunks, status: old.status }; + return old && sameFile(old, file) ? old : file; + }); + const { revision, baseSha, headSha, mergeBaseSha } = pending.begin; + this.committed = { + phase: "ready", + revision, + resolved: { baseSha, headSha, mergeBaseSha }, + files, + diffTooLarge: frame.diffTooLarge, + }; + this.publish(this.committed); + } +} +function sameFile(a: ParsedDiffFile, b: ParsedDiffFile): boolean { + if ( + a.path !== b.path || + a.oldPath !== b.oldPath || + a.isNew !== b.isNew || + a.isDeleted !== b.isDeleted || + a.additions !== b.additions || + a.deletions !== b.deletions || + a.status !== b.status + ) + return false; + if (a.hunks === b.hunks) return true; + return JSON.stringify(a.hunks) === JSON.stringify(b.hunks); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c6c5bb818d..7f1c5fd14f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1377,6 +1377,8 @@ function createGeneratedClientId(): string { return `paseo-sdk-${randomId}`; } +export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js"; + interface WorkspaceLabelAssignment { client: DaemonClient; workspaceId: string; diff --git a/packages/desktop/e2e/browser-fill.electron.mjs b/packages/desktop/e2e/browser-fill.electron.mjs new file mode 100644 index 0000000000..78ce935012 --- /dev/null +++ b/packages/desktop/e2e/browser-fill.electron.mjs @@ -0,0 +1,108 @@ +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; +import { expect } from "@playwright/test"; + +export async function buildFillFixture() { + const { outputFiles } = await build({ + stdin: { + resolveDir: fileURLToPath(new URL("../../app", import.meta.url)), + contents: ` + import { createElement as e, useState } from 'react'; + import { createRoot } from 'react-dom/client'; + function Form() { + const [title, setTitle] = useState(''); + const [notes, setNotes] = useState(''); + const [submitted, setSubmitted] = useState(null); + return e('form', { onSubmit: ev => { + ev.preventDefault(); setSubmitted({ title, notes }); + } }, + e('input', { 'aria-label': 'React title', value: title, + onChange: ev => setTitle(ev.target.value) }), + e('textarea', { 'aria-label': 'React notes', value: notes, + onChange: ev => setNotes(ev.target.value) }), + e('p', null, 'React state: ' + JSON.stringify({ title, notes })), + e('button', { type: 'submit' }, 'Submit'), + e('p', null, 'Submitted: ' + JSON.stringify(submitted))); + } + createRoot(document.getElementById('root')).render(e(Form)); + document.getElementById('plain').addEventListener('input', ev => { + document.getElementById('plain-state').textContent = 'Plain state: ' + ev.target.value; + }); + `, + }, + bundle: true, + write: false, + format: "iife", + platform: "browser", + define: { "process.env.NODE_ENV": '"production"' }, + }); + return `React fill fixture
+

+
+ `; +} + +async function findRef(call, browserId, role, name) { + const { snapshot } = await call("browser_snapshot", { browserId }); + const line = snapshot.split("\n").find((candidate) => candidate.includes(`${role} "${name}"`)); + const ref = line?.match(/\[ref=([^\]]+)\]/)?.[1]; + expect(ref, `Snapshot must expose ${role} ${name}: ${snapshot}`).toBeTruthy(); + return ref; +} + +async function fillField(call, browserId, name, value) { + const ref = await findRef(call, browserId, "textbox", name); + await call("browser_fill", { browserId, ref, value }); +} + +async function submitForm(call, browserId) { + const ref = await findRef(call, browserId, "button", "Submit"); + await call("browser_click", { browserId, ref }); +} + +async function expectPageText(call, browserId, text) { + await expect + .poll(async () => { + const result = await call("browser_evaluate", { + browserId, + function: "() => document.body.innerText", + }); + return JSON.parse(result.resultJson); + }) + .toContain(text); +} + +export async function verifyReactFormFill(call, targetUrl) { + const { browserId } = await call("browser_new_tab", { + url: new URL("/react-fill", targetUrl).href, + }); + try { + await call("browser_wait", { browserId, text: "React state:", timeoutMs: 15_000 }); + await fillField(call, browserId, "Plain input", "plain value"); + await expectPageText(call, browserId, "Plain state: plain value"); + await fillField(call, browserId, "Editable text", "editable value"); + await expectPageText(call, browserId, "editable value"); + await fillField(call, browserId, "React title", "Hello from browser_fill"); + await fillField(call, browserId, "React notes", "First line\nSecond line"); + await expectPageText( + call, + browserId, + 'React state: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}', + ); + await submitForm(call, browserId); + await expectPageText( + call, + browserId, + 'Submitted: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}', + ); + await fillField(call, browserId, "React title", "replacement"); + await fillField(call, browserId, "React notes", ""); + await submitForm(call, browserId); + await expectPageText(call, browserId, 'Submitted: {"title":"replacement","notes":""}'); + await fillField(call, browserId, "React title", ""); + await submitForm(call, browserId); + await expectPageText(call, browserId, 'Submitted: {"title":"","notes":""}'); + } finally { + await call("browser_close_tab", { browserId }); + } +} diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 93e6bd61d1..668d884b80 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -17,6 +17,7 @@ import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs"; import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; +import { buildFillFixture, verifyReactFormFill } from "./browser-fill.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs"; @@ -216,7 +217,13 @@ async function settleDesktopStartup(page) { const CSP_PAGE_PATH = "/csp-no-eval"; async function startTargetPage() { + const fillFixture = await buildFillFixture(); const server = createServer((request, response) => { + if (request.url === "/react-fill") { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(fillFixture); + return; + } if (request.url === CSP_PAGE_PATH) { response.writeHead(200, { "content-type": "text/html; charset=utf-8", @@ -782,6 +789,7 @@ async function runRegression({ document.body.appendChild(overlayRoot); }); + await verifyReactFormFill((name, args) => callBrowserTool(client, name, args), targetUrl); await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl); const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl }); diff --git a/packages/desktop/src/features/browser-automation/snapshot-engine.ts b/packages/desktop/src/features/browser-automation/snapshot-engine.ts index 2ee1a68a5e..e6fc74a3c9 100644 --- a/packages/desktop/src/features/browser-automation/snapshot-engine.ts +++ b/packages/desktop/src/features/browser-automation/snapshot-engine.ts @@ -316,7 +316,16 @@ function buildFillScript(metadata: BrowserRefMetadata, value: string): string { element.focus(); const nextValue = ${JSON.stringify(value)}; if ('value' in element) { - element.value = nextValue; + const view = element.ownerDocument.defaultView; + let prototype = null; + if (element.tagName === 'INPUT') { + prototype = view.HTMLInputElement.prototype; + } else if (element.tagName === 'TEXTAREA') { + prototype = view.HTMLTextAreaElement.prototype; + } + // Bypass instance setters so framework value trackers observe the input event as a change. + if (prototype) Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, nextValue); + else element.value = nextValue; element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); return { ok: true }; diff --git a/packages/protocol/src/binary-frames/demux.ts b/packages/protocol/src/binary-frames/demux.ts index 1a7617e35b..b21118d301 100644 --- a/packages/protocol/src/binary-frames/demux.ts +++ b/packages/protocol/src/binary-frames/demux.ts @@ -1,3 +1,4 @@ +import { decodeDiffFrame, DiffOpcode, type DiffFrame } from "./diff.js"; import { HOST_ROUTE_OPCODE, decodeHostRouteFrame, type HostRouteFrame } from "./host-route.js"; import { decodeFileTransferFrame, @@ -12,12 +13,20 @@ import { } from "./terminal.js"; export type BinaryFrame = + | { kind: "diff"; frame: DiffFrame } | { kind: "host_route"; frame: HostRouteFrame } | { kind: "terminal"; frame: TerminalStreamFrame } | { kind: "file_transfer"; frame: FileTransferFrame } | { kind: "plugin"; frame: PluginFrame }; export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null { + if (Object.values(DiffOpcode).some((opcode) => opcode === bytes[0])) { + return { kind: "diff", frame: decodeDiffFrame(bytes) }; + } + return decodeExistingBinaryFrame(bytes); +} + +function decodeExistingBinaryFrame(bytes: Uint8Array): BinaryFrame | null { switch (bytes[0]) { case HOST_ROUTE_OPCODE: { const frame = decodeHostRouteFrame(bytes); diff --git a/packages/protocol/src/binary-frames/diff.test.ts b/packages/protocol/src/binary-frames/diff.test.ts new file mode 100644 index 0000000000..fcc2885521 --- /dev/null +++ b/packages/protocol/src/binary-frames/diff.test.ts @@ -0,0 +1,270 @@ +import { expect, test } from "vitest"; +import { decodeDiffFrame, encodeDiffFrame } from "./diff.js"; +import { decodeBinaryFrame } from "./demux.js"; +import { decodePluginFrame } from "./plugin.js"; + +test("routes diff revisions independently of plugin frames", () => { + const frame = { + opcode: 0x40 as const, + slot: 0, + revision: 1, + baseRevision: 0, + baseSha: "", + headSha: "", + mergeBaseSha: "", + entries: [], + }; + const bytes = encodeDiffFrame(frame); + expect([...bytes]).toEqual([64, 0, 1, 1, 0, 0, 0, 0, 0]); + expect(decodeDiffFrame(bytes)).toEqual(frame); + expect(decodeBinaryFrame(bytes)).toEqual({ kind: "diff", frame }); + expect(decodePluginFrame(bytes)).toBeNull(); +}); + +test("roundtrips Unicode runs and materializes the existing file shape", async () => { + const { materializeDiffFile } = await import("./diff.js"); + const frame = { + opcode: 0x41 as const, + slot: 255, + revision: 128, + files: [ + { + path: "é.ts", + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { + type: "add" as const, + content: "😀é", + runs: [ + { style: "string", length: 2 }, + { style: null, length: 2 }, + ], + }, + ], + }, + ], + }, + ], + }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + const file = materializeDiffFile( + { + path: "é.ts", + oldPath: null, + change: "modified", + content: "text", + additions: 1, + deletions: 1, + body: true, + }, + frame.files[0], + false, + ); + expect(file.hunks[0].lines[0].tokens).toEqual([ + { text: "😀", style: "string" }, + { text: "é", style: null }, + ]); +}); + +test("preserves binary additions, modifications, deletions and mode-only text", async () => { + const { materializeDiffFile } = await import("./diff.js"); + for (const change of ["added", "modified", "deleted"] as const) { + const entry = { + path: "image.png", + oldPath: null, + change, + content: "binary" as const, + additions: 0, + deletions: 0, + body: false, + }; + const begin = { + opcode: 0x40 as const, + slot: 1, + revision: 1, + baseRevision: 0, + baseSha: "", + headSha: "", + mergeBaseSha: "", + entries: [entry], + }; + expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin); + expect(materializeDiffFile(entry, null, false)).toEqual({ + path: "image.png", + isNew: change === "added", + isDeleted: change === "deleted", + additions: 0, + deletions: 0, + status: "binary", + hunks: [], + }); + } + expect( + materializeDiffFile( + { + path: "script", + oldPath: null, + change: "modified", + content: "text", + additions: 0, + deletions: 0, + body: true, + }, + { path: "script", hunks: [] }, + false, + ), + ).toEqual({ + path: "script", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + status: "ok", + hunks: [], + }); +}); + +test("rejects every truncated prefix, unknown version, trailing data, and invalid coverage", () => { + const end = { + opcode: 0x42 as const, + slot: 1, + revision: 9007199254740991, + diffTooLarge: true, + omitted: ["a.ts"], + error: { code: "REF_NOT_FOUND" as const, message: "missing" }, + }; + const bytes = encodeDiffFrame(end); + expect(decodeDiffFrame(bytes)).toEqual(end); + for (let length = 0; length < bytes.length; length++) + expect(() => decodeDiffFrame(bytes.slice(0, length))).toThrow(); + const version = bytes.slice(); + version[2] = 2; + expect(() => decodeDiffFrame(version)).toThrow("version"); + expect(() => decodeDiffFrame(Uint8Array.from([...bytes, 0]))).toThrow("Trailing"); + expect(() => + encodeDiffFrame({ + opcode: 0x41, + slot: 1, + revision: 1, + files: [ + { + path: "x", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [{ type: "add", content: "😀", runs: [{ style: null, length: 1 }] }], + }, + ], + }, + ], + }), + ).toThrow("cover"); +}); + +test("uses fixed UTF-8 byte vectors while runs count UTF-16 units", () => { + const bytes = Uint8Array.from([ + 65, 0, 1, 1, 1, 1, 120, 1, 0, 0, 1, 1, 1, 129, 7, 240, 159, 152, 128, 101, 204, 129, 1, 3, 4, + ]); + const frame = { + opcode: 0x41 as const, + slot: 0, + revision: 1, + files: [ + { + path: "x", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [ + { type: "add" as const, content: "😀é", runs: [{ style: "string", length: 4 }] }, + ], + }, + ], + }, + ], + }; + expect(encodeDiffFrame(frame)).toEqual(bytes); + expect(decodeDiffFrame(bytes)).toEqual(frame); + const unknownStyle = bytes.slice(); + unknownStyle[23] = 255; + expect(decodeDiffFrame(unknownStyle)).toEqual({ + ...frame, + files: [ + { + ...frame.files[0], + hunks: [ + { + ...frame.files[0].hunks[0], + lines: [{ type: "add", content: "😀é", runs: [{ style: null, length: 4 }] }], + }, + ], + }, + ], + }); +}); + +test("roundtrips generated entry lists across varint boundaries", () => { + for (const revision of [0, 1, 127, 128, 16383, 16384, 4294967296, Number.MAX_SAFE_INTEGER]) { + const entries = Array.from({ length: 130 }, (_, index) => ({ + path: `src/${index}-😀.ts`, + oldPath: `old/${index}.ts`, + change: "renamed" as const, + content: "text" as const, + additions: index * 128, + deletions: index, + body: true, + })); + const begin = { + opcode: 0x40 as const, + slot: 255, + revision, + baseRevision: 0, + baseSha: "base", + headSha: "head", + mergeBaseSha: "merge", + entries, + }; + expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin); + } +}); + +test.each([true, false])( + "preserves BOM-prefixed paths and source lines with runs=%s", + (highlight) => { + const frame = { + opcode: 0x41 as const, + slot: 0, + revision: 1, + files: [ + { + path: "\uFEFFbom.ts", + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: (["add", "remove", "context"] as const).map((type) => ({ + type, + content: "\uFEFFvalue", + runs: highlight ? [{ style: null, length: 6 }] : undefined, + })), + }, + ], + }, + ], + }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + }, +); diff --git a/packages/protocol/src/binary-frames/diff.ts b/packages/protocol/src/binary-frames/diff.ts new file mode 100644 index 0000000000..9f11450b7d --- /dev/null +++ b/packages/protocol/src/binary-frames/diff.ts @@ -0,0 +1,360 @@ +import type { ParsedDiffFile } from "../messages.js"; + +// Opcodes 0x40–0x4f are reserved for diff revisions. Plugins own 0x20–0x2f. +export const DiffOpcode = { Begin: 0x40, Bodies: 0x41, End: 0x42 } as const; +export const DIFF_HIGHLIGHT_STYLES = [ + null, + "keyword", + "comment", + "string", + "number", + "literal", + "function", + "definition", + "class", + "type", + "tag", + "attribute", + "property", + "variable", + "operator", + "punctuation", + "regexp", + "escape", + "meta", + "heading", + "link", +] as const; +const CHANGES = ["added", "modified", "deleted", "renamed"] as const; +const CONTENTS = ["text", "binary"] as const; +const KINDS = ["context", "add", "remove", "header"] as const; +const ERRORS = [ + null, + "NOT_GIT_REPO", + "REF_NOT_FOUND", + "INVALID_COMPARE", + "TOO_MANY_SUBSCRIPTIONS", + "UNKNOWN", +] as const; +export interface DiffEntry { + path: string; + oldPath: string | null; + change: (typeof CHANGES)[number]; + content: (typeof CONTENTS)[number]; + additions: number; + deletions: number; + body: boolean; +} +export interface DiffRun { + style: string | null; + length: number; +} +export interface DiffStreamLine { + type: (typeof KINDS)[number]; + content: string; + runs?: DiffRun[]; +} +export interface DiffStreamHunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: DiffStreamLine[]; +} +export interface DiffBody { + path: string; + hunks: DiffStreamHunk[]; +} +export interface DiffBegin { + opcode: typeof DiffOpcode.Begin; + slot: number; + revision: number; + baseRevision: number; + baseSha: string; + headSha: string; + mergeBaseSha: string; + entries: DiffEntry[]; +} +export interface DiffBodies { + opcode: typeof DiffOpcode.Bodies; + slot: number; + revision: number; + files: DiffBody[]; +} +export interface DiffEnd { + opcode: typeof DiffOpcode.End; + slot: number; + revision: number; + diffTooLarge: boolean; + omitted: string[]; + error: { code: NonNullable<(typeof ERRORS)[number]>; message: string } | null; +} +export type DiffFrame = DiffBegin | DiffBodies | DiffEnd; +export class InvalidDiffFrameError extends Error {} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +class Writer { + private bytes: number[] = []; + byte(value: number): void { + if (!Number.isInteger(value) || value < 0 || value > 255) + throw new InvalidDiffFrameError("Invalid byte"); + this.bytes.push(value); + } + uint(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) + throw new InvalidDiffFrameError("Invalid unsigned integer"); + do { + const next = value % 128; + value = Math.floor(value / 128); + this.byte(next + (value ? 128 : 0)); + } while (value); + } + str(value: string): void { + const bytes = textEncoder.encode(value); + this.uint(bytes.length); + for (const byte of bytes) this.byte(byte); + } + finish(): Uint8Array { + return Uint8Array.from(this.bytes); + } +} +class Reader { + private offset = 0; + constructor(private bytes: Uint8Array) {} + byte(): number { + if (this.offset >= this.bytes.length) throw new InvalidDiffFrameError("Truncated diff frame"); + return this.bytes[this.offset++]; + } + uint(): number { + let value = 0; + let factor = 1; + for (let index = 0; index < 8; index++) { + const byte = this.byte(); + value += (byte & 127) * factor; + if (!Number.isSafeInteger(value)) throw new InvalidDiffFrameError("Integer overflow"); + if (byte < 128) return value; + factor *= 128; + } + throw new InvalidDiffFrameError("Invalid varint"); + } + count(): number { + const value = this.uint(); + if (value > this.bytes.length - this.offset) + throw new InvalidDiffFrameError("Count exceeds frame"); + return value; + } + str(): string { + const size = this.count(); + const start = this.offset; + this.offset += size; + try { + return textDecoder.decode(this.bytes.subarray(start, this.offset)); + } catch { + throw new InvalidDiffFrameError("Invalid UTF-8"); + } + } + done(): void { + if (this.offset !== this.bytes.length) throw new InvalidDiffFrameError("Trailing bytes"); + } +} +function enumAt(values: readonly T[], index: number): T { + if (index >= values.length) throw new InvalidDiffFrameError("Unknown enum value"); + return values[index]; +} +function validateRuns(line: DiffStreamLine): void { + if (!line.runs) return; + let length = 0; + for (const run of line.runs) { + if (!Number.isSafeInteger(run.length) || run.length <= 0) + throw new InvalidDiffFrameError("Invalid run length"); + length += run.length; + } + if (length !== line.content.length) throw new InvalidDiffFrameError("Runs do not cover line"); +} +function writeLine(writer: Writer, line: DiffStreamLine): void { + validateRuns(line); + writer.byte(KINDS.indexOf(line.type) | (line.runs ? 128 : 0)); + writer.str(line.content); + writer.uint(line.runs ? line.runs.length : 0); + for (const run of line.runs ?? []) { + const style = DIFF_HIGHLIGHT_STYLES.findIndex((value) => value === run.style); + writer.byte(Math.max(0, style)); + writer.uint(run.length); + } +} +function readLine(reader: Reader): DiffStreamLine { + const flags = reader.byte(); + const type = enumAt(KINDS, flags & 127); + const content = reader.str(); + const count = reader.count(); + const runs: DiffRun[] = []; + for (let index = 0; index < count; index++) { + const styleIndex = reader.byte(); + const style = DIFF_HIGHLIGHT_STYLES[styleIndex] ?? null; + runs.push({ style, length: reader.uint() }); + } + if (!(flags & 128)) { + if (count) throw new InvalidDiffFrameError("Runs without flag"); + return { type, content }; + } + const line = { type, content, runs }; + validateRuns(line); + return line; +} +export function encodeDiffFrame(frame: DiffFrame): Uint8Array { + const writer = new Writer(); + writer.byte(frame.opcode); + writer.byte(frame.slot); + writer.byte(1); + writer.uint(frame.revision); + if (frame.opcode === DiffOpcode.Begin) { + writer.uint(frame.baseRevision); + writer.str(frame.baseSha); + writer.str(frame.headSha); + writer.str(frame.mergeBaseSha); + writer.uint(frame.entries.length); + for (const entry of frame.entries) { + if (entry.content === "binary" && entry.body) + throw new InvalidDiffFrameError("Binary entry has a body"); + writer.str(entry.path); + writer.str(entry.oldPath ?? ""); + writer.byte(CHANGES.indexOf(entry.change)); + writer.byte(CONTENTS.indexOf(entry.content)); + writer.uint(entry.additions); + writer.uint(entry.deletions); + writer.byte(Number(entry.body)); + } + } else if (frame.opcode === DiffOpcode.Bodies) { + writer.uint(frame.files.length); + for (const file of frame.files) { + writer.str(file.path); + writer.uint(file.hunks.length); + for (const hunk of file.hunks) { + writer.uint(hunk.oldStart); + writer.uint(hunk.oldCount); + writer.uint(hunk.newStart); + writer.uint(hunk.newCount); + writer.uint(hunk.lines.length); + for (const line of hunk.lines) writeLine(writer, line); + } + } + } else { + writer.byte(Number(frame.diffTooLarge)); + writer.uint(frame.omitted.length); + for (const path of frame.omitted) writer.str(path); + writer.byte(frame.error ? ERRORS.indexOf(frame.error.code) : 0); + writer.str(frame.error ? frame.error.message : ""); + } + return writer.finish(); +} +export function decodeDiffFrame(bytes: Uint8Array): DiffFrame { + const reader = new Reader(bytes); + const opcode = reader.byte(); + const slot = reader.byte(); + if (reader.byte() !== 1) throw new InvalidDiffFrameError("Unknown diff version"); + const revision = reader.uint(); + let frame: DiffFrame; + if (opcode === DiffOpcode.Begin) { + const baseRevision = reader.uint(); + const baseSha = reader.str(); + const headSha = reader.str(); + const mergeBaseSha = reader.str(); + const count = reader.count(); + const entries: DiffEntry[] = []; + for (let index = 0; index < count; index++) { + const path = reader.str(); + const oldPath = reader.str() || null; + const change = enumAt(CHANGES, reader.byte()); + const content = enumAt(CONTENTS, reader.byte()); + const additions = reader.uint(); + const deletions = reader.uint(); + const bodyByte = reader.byte(); + if (bodyByte > 1 || (content === "binary" && bodyByte)) + throw new InvalidDiffFrameError("Invalid entry body"); + entries.push({ path, oldPath, change, content, additions, deletions, body: bodyByte === 1 }); + } + frame = { opcode, slot, revision, baseRevision, baseSha, headSha, mergeBaseSha, entries }; + } else if (opcode === DiffOpcode.Bodies) { + const count = reader.count(); + const files: DiffBody[] = []; + for (let index = 0; index < count; index++) { + const path = reader.str(); + const hunkCount = reader.count(); + const hunks: DiffStreamHunk[] = []; + for (let h = 0; h < hunkCount; h++) { + const oldStart = reader.uint(); + const oldCount = reader.uint(); + const newStart = reader.uint(); + const newCount = reader.uint(); + const lineCount = reader.count(); + const lines: DiffStreamLine[] = []; + for (let l = 0; l < lineCount; l++) lines.push(readLine(reader)); + hunks.push({ oldStart, oldCount, newStart, newCount, lines }); + } + files.push({ path, hunks }); + } + frame = { opcode, slot, revision, files }; + } else if (opcode === DiffOpcode.End) { + const flags = reader.byte(); + if (flags > 1) throw new InvalidDiffFrameError("Unknown flags"); + const count = reader.count(); + const omitted: string[] = []; + for (let index = 0; index < count; index++) omitted.push(reader.str()); + const code = enumAt(ERRORS, reader.byte()); + const message = reader.str(); + frame = { + opcode, + slot, + revision, + diffTooLarge: flags === 1, + omitted, + error: code ? { code, message } : null, + }; + } else { + throw new InvalidDiffFrameError("Unknown diff opcode"); + } + reader.done(); + return frame; +} +export function runsToTokens( + line: DiffStreamLine, +): NonNullable { + let offset = 0; + return (line.runs ?? []).map((run) => { + const text = line.content.slice(offset, offset + run.length); + offset += run.length; + return { text, style: run.style }; + }); +} +export function materializeDiffFile( + entry: DiffEntry, + body: DiffBody | null, + omitted: boolean, +): ParsedDiffFile { + let status: "ok" | "binary" | "too_large" = "ok"; + if (entry.content === "binary") status = "binary"; + else if (omitted) status = "too_large"; + const hunks = body + ? body.hunks.map((hunk) => ({ + ...hunk, + lines: hunk.lines.map((line) => { + if (line.runs) + return { type: line.type, content: line.content, tokens: runsToTokens(line) }; + return { type: line.type, content: line.content }; + }), + })) + : []; + const file: ParsedDiffFile = { + path: entry.path, + isNew: entry.change === "added", + isDeleted: entry.change === "deleted", + additions: entry.additions, + deletions: entry.deletions, + hunks, + status, + }; + if (entry.oldPath !== null) file.oldPath = entry.oldPath; + return file; +} diff --git a/packages/protocol/src/binary-frames/index.ts b/packages/protocol/src/binary-frames/index.ts index bb2e7e002d..946a1c7051 100644 --- a/packages/protocol/src/binary-frames/index.ts +++ b/packages/protocol/src/binary-frames/index.ts @@ -3,3 +3,4 @@ export * from "./file-transfer.js"; export * from "./terminal.js"; export * from "./host-route.js"; export * from "./plugin.js"; +export * from "./diff.js"; diff --git a/packages/protocol/src/messages.diff.test.ts b/packages/protocol/src/messages.diff.test.ts new file mode 100644 index 0000000000..f2831ab147 --- /dev/null +++ b/packages/protocol/src/messages.diff.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; +import { DiffSubscribeRequestSchema, DiffSubscribeResponseSchema } from "./messages.js"; + +test("accepts explicit endpoints without changing legacy schemas", () => { + const request = { + type: "diff.subscribe.request", + requestId: "r", + cwd: "/repo", + base: { kind: "rev", rev: "v1" }, + head: { kind: "worktree" }, + mergeBase: true, + highlight: false, + }; + expect(DiffSubscribeRequestSchema.parse(request)).toEqual(request); + expect( + DiffSubscribeRequestSchema.safeParse({ ...request, base: { kind: "unknown" } }).success, + ).toBe(false); + expect( + DiffSubscribeResponseSchema.safeParse({ + type: "diff.subscribe.response", + payload: { requestId: "r", subscriptionId: "s", slot: 256, error: null }, + }).success, + ).toBe(false); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index bd11165915..bab4a37e23 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2406,6 +2406,51 @@ const CheckoutDiffCompareSchema = z.object({ ignoreWhitespace: z.boolean().optional(), }); +export const DiffEndpointSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("rev"), rev: z.string() }), + z.object({ kind: z.literal("index") }), + z.object({ kind: z.literal("worktree") }), + z.object({ kind: z.literal("empty") }), +]); +export const DiffErrorSchema = z.object({ + code: z.enum([ + "NOT_GIT_REPO", + "REF_NOT_FOUND", + "INVALID_COMPARE", + "TOO_MANY_SUBSCRIPTIONS", + "UNKNOWN", + ]), + message: z.string(), +}); +export const DiffSubscribeRequestSchema = z.object({ + type: z.literal("diff.subscribe.request"), + requestId: z.string(), + cwd: z.string(), + base: DiffEndpointSchema, + head: DiffEndpointSchema, + mergeBase: z.boolean().optional(), + ignoreWhitespace: z.boolean().optional(), + highlight: z.boolean().optional(), +}); +// Unsubscribe is fire-and-forget; release has no correlated response. +export const DiffUnsubscribeRequestSchema = z.object({ + type: z.literal("diff.unsubscribe.request"), + subscriptionId: z.string(), +}); +export const DiffSubscribeResponseSchema = z.object({ + type: z.literal("diff.subscribe.response"), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string(), + slot: z.number().int().min(0).max(255), + error: DiffErrorSchema.nullable(), + }), +}); +export type DiffEndpoint = z.infer; +export type DiffError = z.infer; +export type DiffSubscribeRequest = z.infer; +export type DiffSubscribeResponse = z.infer; + export const CheckoutStatusRequestSchema = z.object({ type: z.literal("checkout_status_request"), cwd: z.string(), @@ -3563,6 +3608,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentDetachRequestMessageSchema, AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, + DiffSubscribeRequestSchema, + DiffUnsubscribeRequestSchema, CheckoutStatusRequestSchema, CheckoutDiffGetRequestSchema, SubscribeCheckoutDiffRequestSchema, @@ -4031,6 +4078,7 @@ export const ServerInfoStatusPayloadSchema = z ownedSubscriptions: z.boolean().optional(), providerOptionControls: z.boolean().optional(), entityMetadata: z.boolean().optional(), + diffSubscription: z.boolean().optional(), binaryChunkingV1: z.boolean().optional(), workspaceSetupObservation: z.boolean().optional(), workspaceSortTimes: z.boolean().optional(), @@ -5622,6 +5670,7 @@ const CheckoutStatusCommonSchema = z.object({ error: CheckoutErrorSchema.nullable(), requestId: z.string(), baseRefExact: z.string().nullable().optional(), + comparisonBaseRef: z.string().nullable().optional(), // The full ref currentBranch tracks, as git resolves `@{upstream}`: // "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a // "refs/heads/..." ref for a branch tracking a local branch. Null when there is no @@ -7450,6 +7499,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentDeletedMessageSchema, AgentArchivedMessageSchema, CloseItemsResponseSchema, + DiffSubscribeResponseSchema, CheckoutStatusResponseSchema, CheckoutStatusUpdateSchema, CheckoutDiffGetResponseSchema, diff --git a/packages/relay/src/handshake-lifetime.test.ts b/packages/relay/src/handshake-lifetime.test.ts new file mode 100644 index 0000000000..ea99005916 --- /dev/null +++ b/packages/relay/src/handshake-lifetime.test.ts @@ -0,0 +1,60 @@ +import { expect, it, vi } from "vitest"; +import { exportPublicKey, generateKeyPair } from "./crypto.js"; +import type { Transport } from "./encrypted-channel.js"; +import { openClientSecureChannel } from "./secure-channel.js"; + +/** A socket whose peer never acknowledges the close handshake. */ +class UnresponsiveTransport implements Transport { + onmessage: Transport["onmessage"] = null; + onclose: Transport["onclose"] = null; + onerror: Transport["onerror"] = null; + sends = 0; + closing = false; + + send(): void { + this.sends += 1; + if (this.closing) throw new Error("WebSocket not open (readyState=2)"); + } + + close(): void { + this.closing = true; + } +} + +it.each(["client close", "handshake rejection"] as const)( + "stops handshake retries after %s without a socket acknowledgment", + async (trigger) => { + vi.useFakeTimers(); + const transport = new UnresponsiveTransport(); + const errors: Error[] = []; + let closed = 0; + try { + const channel = await openClientSecureChannel(transport, { + daemonPublicKeyB64: exportPublicKey(generateKeyPair().publicKey), + minimumMode: "protected", + events: { onerror: (error) => errors.push(error) }, + }); + channel.onClose(() => closed++); + + // The handshake still retries while the connection is wanted. + await vi.advanceTimersByTimeAsync(2_000); + expect(transport.sends).toBe(3); + if (trigger === "client close") { + channel.close(); + } else { + transport.onmessage?.({ data: JSON.stringify({ type: "e2ee_ready" }), isBinary: false }); + } + await vi.advanceTimersByTimeAsync(5_000); + + expect(transport.sends).toBe(3); + expect(errors).toHaveLength(trigger === "client close" ? 0 : 1); + expect(closed).toBe(1); + // A late socket acknowledgment does not close the channel a second time. + transport.onclose?.(1000, "Normal closure"); + expect(closed).toBe(1); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }, +); diff --git a/packages/relay/src/secure-channel.ts b/packages/relay/src/secure-channel.ts index 760dcc49d0..49cbce1a53 100644 --- a/packages/relay/src/secure-channel.ts +++ b/packages/relay/src/secure-channel.ts @@ -344,8 +344,7 @@ class ClientSecureChannel implements SecureChannel { private fail(code: number, reason: string): void { if (this.state === "closed") return; - this.state = "closed"; - this.handshake = null; + this.notifyClosed(); try { this.transport.close(code, reason); } catch { @@ -364,13 +363,12 @@ class ClientSecureChannel implements SecureChannel { } close(code = 1000, reason = "Normal closure"): void { + this.notifyClosed(); if (this.inner) { this.inner.close(code, reason); } else { this.transport.close(code, reason); } - this.state = "closed"; - this.handshake = null; } isOpen(): boolean { diff --git a/packages/server/package.json b/packages/server/package.json index 0733102d65..922e611733 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -102,7 +102,8 @@ "test:e2e:real": "npm run test:integration:real", "test:e2e:local": "npm run test:integration:local", "test:e2e:relay:live": "cross-env RUN_LIVE_RELAY_E2E=1 vitest run src/server/daemon-e2e/live-relay.real.e2e.test.ts --maxWorkers=1", - "test:e2e:ui": "vitest --ui e2e.test.ts" + "test:e2e:ui": "vitest --ui e2e.test.ts", + "measure:diff-subscriptions": "tsx scripts/measure-diff-subscriptions.ts" }, "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", diff --git a/packages/server/scripts/measure-diff-subscriptions.ts b/packages/server/scripts/measure-diff-subscriptions.ts new file mode 100644 index 0000000000..e8e058708e --- /dev/null +++ b/packages/server/scripts/measure-diff-subscriptions.ts @@ -0,0 +1,111 @@ +import { performance } from "node:perf_hooks"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + encodeDiffFrame, + DiffOpcode, + type DiffFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import { DiffSubscriptions } from "../src/server/diff/index.js"; +import { createFileObserver } from "../src/server/file-observer/index.js"; +import { runGitCommand } from "../src/utils/run-git-command.js"; +import { parseAndHighlightDiff } from "../src/server/utils/diff-highlighter.js"; +import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound"; + +const cwd = resolve(import.meta.dirname, "../../.."); +const artifacts = resolve(cwd, ".artifacts/diff-subscription"); +await mkdir(artifacts, { recursive: true }); +const pinnedHead = "3ed57e9dd"; +const observer = createFileObserver(); +const engine = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, changed) { + const watch = await observer.subscribe(root, () => changed(), { + ignore: [resolve(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await watch.unsubscribe(); + }, + }; + }, +}); +try { + for (const count of [1, 30]) { + const [base, head] = await Promise.all([ + runGitCommand(["rev-parse", `${pinnedHead}~${count}`], { cwd }), + runGitCommand(["rev-parse", pinnedHead], { cwd }), + ]); + const started = performance.now(); + const frames: DiffFrame[] = []; + const encoded: Uint8Array[] = []; + const subscription = await engine.subscribe( + { + cwd, + base: { kind: "rev", rev: base.stdout.trim() }, + head: { kind: "rev", rev: head.stdout.trim() }, + }, + async (frame) => { + frames.push(frame); + encoded.push(encodeDiffFrame(frame)); + }, + ); + await subscription.start(); + const coldMs = performance.now() - started; + await subscription.release(); + const raw = await runGitCommand(["diff", base.stdout.trim(), head.stdout.trim()], { + cwd, + maxOutputBytes: 50 * 1024 * 1024, + }); + const contentAt = async (sha: string, path: string) => { + const result = await runGitCommand(["show", `${sha}:${path}`], { + cwd, + acceptExitCodes: [0, 128], + }); + return result.exitCode === 0 ? result.stdout : null; + }; + const legacyFiles = await parseAndHighlightDiff(raw.stdout, cwd, { + getOldFileContent: (file) => contentAt(base.stdout.trim(), file.path), + getNewFileContent: (file) => contentAt(head.stdout.trim(), file.path), + }); + const legacy = JSON.stringify({ + type: "session", + message: { + type: "checkout_diff_update", + payload: { cwd, subscriptionId: "benchmark", files: legacyFiles, error: null }, + }, + }); + const parseStart = performance.now(); + const validation = validateWSOutboundMessage(JSON.parse(legacy)); + if (!validation.success) throw validation.error; + const jsonParseValidationMs = performance.now() - parseStart; + const begin = frames.find((frame) => frame.opcode === DiffOpcode.Begin); + const end = frames.findLast((frame) => frame.opcode === DiffOpcode.End); + if (!begin || begin.opcode !== DiffOpcode.Begin || !end || end.opcode !== DiffOpcode.End) + throw new Error("Revision was not delimited"); + if (end.error) throw new Error(end.error.message); + const metrics = { + pinnedHead: head.stdout.trim(), + count, + files: begin.entries.length, + rawPatchBytes: Buffer.byteLength(raw.stdout), + encodedBytes: encoded.reduce((sum, bytes) => sum + bytes.length, 0), + legacyJsonBytes: Buffer.byteLength(legacy), + coldMs, + jsonParseValidationMs, + omitted: end.omitted, + }; + console.log(JSON.stringify(metrics)); + await writeFile( + resolve(artifacts, `corpus-${count}.json`), + JSON.stringify({ + metrics, + legacy, + frames: encoded.map((bytes) => Buffer.from(bytes).toString("base64")), + }), + ); + } +} finally { + await engine.close(); + await observer.close(); +} diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 29b7fa06f8..839d4f2a95 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -469,7 +469,7 @@ class TestAgentSession implements AgentSession { async startTurn(): Promise<{ turnId: string }> { this.interrupted = false; - const turnId = `turn-${++this.turnIdCounter}`; + const turnId = `turn-${this.id}-${++this.turnIdCounter}`; // Use setTimeout so events arrive after the caller sets up the foreground waiter setTimeout(() => { this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); @@ -14151,3 +14151,35 @@ test("stops only a currently owned public background item with its current token expect(stopped).toEqual(["one"]); await manager.closeAgent(agent.id); }); + +test("turns keep distinct identities across an idle runtime collection and resume", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-identity-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new TestAgentClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const collect = runtimeCleanup(manager); + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const consume = async (prompt: string, clientMessageId: string) => { + for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) { + } + }; + await consume("first", "first"); + const later = new Date(Date.now() + 61 * 60_000); + expect((await collect({ now: later })).collected).toHaveLength(1); + await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger }); + await consume("second", "second"); + const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows; + const userTurnIds = rows + .filter((row) => row.item.type === "user_message") + .map((row) => row.turnId); + expect(userTurnIds).toHaveLength(2); + expect(new Set(userTurnIds).size).toBe(2); + } finally { + await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 85b798235c..99a20d37f5 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2721,12 +2721,30 @@ export class AgentManager { } async clearAgentAttention(agentId: string): Promise { - const agent = this.requireAgent(agentId); - if (agent.attention.requiresAttention) { - agent.attention = { requiresAttention: false }; - await this.persistSnapshot(agent); - this.emitState(agent, { persist: false }); + const agent = this.agents.get(agentId); + if (agent) { + if (agent.attention.requiresAttention) { + agent.attention = { requiresAttention: false }; + await this.persistSnapshot(agent); + this.emitState(agent, { persist: false }); + } + return; } + + const registry = this.requireRegistry(); + const record = await registry.get(agentId); + if (!record || record.internal || record.archivedAt || record.requiresAttention !== true) { + return; + } + const nextRecord: StoredAgentRecord = { + ...record, + updatedAt: this.nextStoredUpdatedAt(record), + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + }; + await registry.upsert(nextRecord); + this.dispatchStoredAgentState(nextRecord); } async markAgentUnread(agentId: string): Promise { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index bd72f4ed08..0267876d0d 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -704,6 +704,7 @@ export interface AgentSession { * replay them at their original timestamps; restored sessions omit old rows. */ readonly initialTimeline?: ImportedTimelineEntry[]; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; + /** Returns an ID unique across the agent lifetime, including resumed sessions and daemon restarts. */ startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; subscribe(callback: (event: AgentStreamEvent) => void): () => void; diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 5c70f0a1fe..432071741f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -806,6 +806,57 @@ test("emits inventory only when relevant observations change it", () => { ).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]); }); +test("Claude runtime safety releases a process that has not been sent a turn", () => { + const safety = new ClaudeRuntimeSafety(); + // A resumed process that only answered control requests never sends init. + expect(safety.canStopRuntime()).toBe(true); + safety.notePromptSent(); + expect(safety.canStopRuntime()).toBe(false); + safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" } as SDKMessage); + expect(safety.canStopRuntime()).toBe(true); + safety.reset(); + expect(safety.canStopRuntime()).toBe(true); + // A CLI without a roster that ran a turn may own background work. + safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" } as SDKMessage); + expect(safety.canStopRuntime()).toBe(false); + safety.retire(); + expect(safety.canStopRuntime()).toBe(false); +}); + +test("Claude session started by a control request without a turn can stop its runtime", async () => { + let launched = 0; + const query = { + next: () => new Promise>(() => {}), + return: async () => ({ done: true, value: undefined }), + close: () => {}, + interrupt: async () => {}, + setPermissionMode: async () => {}, + setModel: async () => {}, + supportedModels: async () => [], + supportedCommands: async () => [], + [Symbol.asyncIterator]() { + return this; + }, + } as unknown as Query; + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + queryFactory: ({ prompt }) => { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + launched++; + return query; + }, + }).createSession({ provider: "claude", cwd: process.cwd() }); + try { + // Reopening a chat lists its commands, which launches Claude with no prompt. + await session.listCommands!(); + expect(launched).toBe(1); + expect(await session.canStopRuntime!()).toBe(true); + } finally { + await session.close(); + } +}); + test("Claude session runtime safety observes pumped background work and autonomous replies", async () => { let wake: (() => void) | undefined; const channel = { closed: false }; diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 5393926520..dc0e468af7 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -1206,7 +1206,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou ); const firstRunId = internal.autonomousTurn?.id ?? null; - expect(firstRunId).toBe("autonomous-turn-1"); + expect(firstRunId).toEqual(expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/)); expect(internal.nextTurnOrdinal).toBe(2); await internal.routeSdkMessageFromPump( @@ -1360,7 +1360,9 @@ test("a main-session frame opens no turn until Claude starts one", async () => { // session start a turn of its own, with no prompt from the user. await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock); - expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + expect(internal.autonomousTurn?.id).toEqual( + expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/), + ); await internal.routeSdkMessageFromPump(successResult(), queryMock); expect(internal.autonomousTurn).toBeNull(); @@ -1410,7 +1412,9 @@ test("Claude waking up after a helper finishes gets an autonomous turn that its expect(internal.autonomousTurn).toBeNull(); await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); - expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + expect(internal.autonomousTurn?.id).toEqual( + expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/), + ); expect(internal.turnState).toBe("autonomous"); await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock); @@ -1446,7 +1450,9 @@ test("a steer folded into a turn keeps one turn, and a turn Claude starts after // A message that arrived while Claude wrote its final answer runs as a turn of its own. await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); - expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2"); + expect(internal.autonomousTurn?.id).toEqual( + expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-2$/), + ); await internal.routeSdkMessageFromPump(successResult(), queryMock); expect(internal.autonomousTurn).toBeNull(); expect(internal.turnState).toBe("idle"); @@ -1483,7 +1489,9 @@ test("an idle session state closes an autonomous turn that no result closed", as session.subscribe((event) => events.push(event)); await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); - expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + expect(internal.autonomousTurn?.id).toEqual( + expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/), + ); // Safety net: Claude says nothing at all is running any more. await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); @@ -1685,7 +1693,9 @@ test("a new turn clears an interrupt flag no result will consume", async () => { await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); expect(internal.pendingInterruptAbort).toBe(false); - expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + expect(internal.autonomousTurn?.id).toEqual( + expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/), + ); // This turn's own failure is reported, not swallowed as the interrupted turn's leftover. await internal.routeSdkMessageFromPump( diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index fd989e8cd1..e47fcb6da7 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1381,6 +1381,30 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("keeps turn identities distinct across resumed session objects", async () => { + const { queryFactory } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const handle = { + provider: "claude" as const, + sessionId: "saved-session", + metadata: { cwd: process.cwd() }, + }; + const first = await client.resumeSession(handle); + const firstTurn = await first.startTurn("first"); + await first.close(); + const resumed = await client.resumeSession(handle); + try { + const nextTurn = await resumed.startTurn("second"); + expect(nextTurn.turnId).not.toBe(firstTurn.turnId); + } finally { + await resumed.close(); + } + }); + test("disables Claude hooks for internal agents only", async () => { const { queryFactory } = createQueryMock(); const client = new ClaudeAgentClient({ diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index d34f7c3707..bee13ed78e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2380,6 +2380,7 @@ class ClaudeAgentSession implements AgentSession { private historySessionId: string | null = null; private replayingHistory = false; private turnState: TurnState = "idle"; + private readonly turnNamespace = randomUUID(); private nextTurnOrdinal = 1; private cancelCurrentTurn: (() => void) | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; @@ -2600,6 +2601,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundQuery = this.query; this.activeForegroundInput = this.input; this.startQueryPump(); + this.runtimeSafety.notePromptSent(); this.input.push(sdkMessage); setTimeout(() => { if (this.activeForegroundTurnId === turnId) { @@ -4016,7 +4018,7 @@ class ClaudeAgentSession implements AgentSession { } private createTurnId(owner: "foreground" | "autonomous"): string { - return `${owner}-turn-${this.nextTurnOrdinal++}`; + return `${owner}-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`; } private isTerminalTurnEvent(event: AgentStreamEvent): boolean { diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.ts index a21bf908ea..8688e93f45 100644 --- a/packages/server/src/server/agent/providers/claude/runtime-safety.ts +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.ts @@ -6,6 +6,10 @@ import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background- /** Process-local background ownership. Completion delivery can follow an empty roster. */ export class ClaudeRuntimeSafety { private inventoryState: "unknown" | "supported" | "retired" = "unknown"; + // Background work starts inside a turn, and init only arrives with a turn. A process that + // has neither been sent a prompt nor reported init (a resumed chat that only answered + // control requests) owns no background work. + private turnSeen = false; private liveTasks: readonly ClaudeBackgroundTask[] = []; private published: AgentBackgroundWorkInput[] | undefined; private readonly awaitingNotifications = new Set(); @@ -15,6 +19,7 @@ export class ClaudeRuntimeSafety { reset(): void { this.inventoryState = "unknown"; + this.turnSeen = false; this.liveTasks = []; this.published = undefined; this.awaitingNotifications.clear(); @@ -28,6 +33,11 @@ export class ClaudeRuntimeSafety { this.inventoryState = "retired"; } + /** Called before a prompt is written to this process. */ + notePromptSent(): void { + this.turnSeen = true; + } + /** Returns a full replacement only when the visible inventory changes. */ observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined { if (this.inventoryState === "retired") return undefined; @@ -93,6 +103,7 @@ export class ClaudeRuntimeSafety { ); } if (message.subtype === "init") { + this.turnSeen = true; const version = parseClaudeCodeVersion(message.claude_code_version ?? ""); if ( version && @@ -115,6 +126,7 @@ export class ClaudeRuntimeSafety { } canStopRuntime(): boolean { + if (this.inventoryState === "unknown" && !this.turnSeen) return true; return ( this.inventoryState === "supported" && this.liveTasks.length === 0 && diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts index 76c46c1d5c..3303777b4e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { createServer } from "node:http"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { once } from "node:events"; import { AgentManager } from "../agent-manager.js"; @@ -217,6 +217,33 @@ function waitForEvent(params: { } describe("Codex app-server provider (local e2e)", () => { + test.runIf(isCodexInstalled())( + "concurrent import scans initialize successfully in a fresh shared Codex home", + async () => { + const artifacts = path.resolve(".artifacts"); + mkdirSync(artifacts, { recursive: true }); + const root = mkdtempSync(path.join(artifacts, "codex-concurrent-startup-")); + try { + for (let round = 0; round < 4; round++) { + const codexHome = path.join(root, String(round)); + mkdirSync(codexHome); + const provider = new CodexAppServerAgentClient(createTestLogger(), { + env: { CODEX_HOME: codexHome }, + }); + const results = await Promise.allSettled( + Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 })), + ); + expect(results).toEqual( + Array.from({ length: 24 }, () => ({ status: "fulfilled", value: [] })), + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + 60_000, + ); + test.runIf(isCodexInstalled())( "reloads a persisted idle thread repeatedly without overlapping writers", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts index 67d0230c0a..e0fa956786 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts @@ -587,6 +587,10 @@ test("concurrent session APIs share one reconnect after an idle provider exit", createFakeCodexAppServer(), ]; let spawnCount = 0; + let markReconnectStarted!: () => void; + const reconnectStarted = new Promise((resolve) => { + markReconnectStarted = resolve; + }); let releaseReconnect: (() => void) | undefined; const reconnectGate = new Promise((resolve) => { releaseReconnect = resolve; @@ -602,6 +606,7 @@ test("concurrent session APIs share one reconnect after an idle provider exit", } spawnCount += 1; if (spawnCount > 1) { + markReconnectStarted(); await reconnectGate; } return appServer.child; @@ -614,12 +619,14 @@ test("concurrent session APIs share one reconnect after an idle provider exit", const runtimeInfo = session.getRuntimeInfo(); const turnStart = session.startTurn("continue after reconnect"); + await reconnectStarted; const reconnectSpawnCount = spawnCount - 1; releaseReconnect?.(); await expect(runtimeInfo).resolves.toMatchObject({ provider: "codex" }); await expect(turnStart).resolves.toMatchObject({ turnId: expect.any(String) }); expect(reconnectSpawnCount).toBe(1); + expect(spawnCount).toBe(2); } finally { releaseReconnect?.(); await session.close(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts index bff992e2ff..8cb8649e26 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts @@ -35,15 +35,14 @@ describe("CodexAppServerAgentClient spawn error handling", () => { }); test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => { - const client = new CodexAppServerAgentClient(logger, { - command: { - mode: "replace", - argv: ["/nonexistent/codex-binary-that-does-not-exist"], - }, - }); const child = createCodexAppServerChildProcess(); const kill = vi.spyOn(child, "kill"); - Object.assign(client, { spawnAppServer: async () => child }); + const client = new CodexAppServerAgentClient(logger, undefined, { + appServerProcess: { + resolvePrefix: async () => ({ command: "codex", args: [] }), + spawn: () => child, + }, + }); await expect( runProviderRefreshWithDeadline({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index de06b514d6..9020202547 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -87,6 +87,7 @@ describe("Codex executable discovery", () => { import { CodexAppServerClient } from "./codex/app-server-transport.js"; import { + createCodexAppServerChildProcess, createFakeCodexAppServer, fakeCodexThreadHistory, type FakeCodexAppServer, @@ -185,18 +186,24 @@ function createProviderWithFakeAppServer( ): CodexAppServerAgentClient { const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, { customProvider: options?.customProvider, + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), }); const internals = castInternals<{ goalsEnabledPromise: Promise | null; autoReviewEnabledPromise: Promise | null; - spawnAppServer: () => Promise; }>(provider); internals.goalsEnabledPromise = Promise.resolve(false); internals.autoReviewEnabledPromise = Promise.resolve(false); - internals.spawnAppServer = async () => appServer.child; return provider; } +function createFakeCodexAppServerProcess(spawn: () => ChildProcessWithoutNullStreams) { + return { + resolvePrefix: async () => ({ command: "codex", args: [] }), + spawn: () => spawn(), + }; +} + async function startPublicSteeringSession( appServer: FakeCodexAppServer, resolveSlashCommandInvocation?: (prompt: AgentPromptInput) => Promise<{ @@ -626,24 +633,25 @@ function codexAgentItem(text: string) { // app-server serving `threads` from thread/read. function createReopenableCodexAgent(threads: Record) { const appServers: FakeCodexAppServer[] = []; - const provider = new CodexAppServerAgentClient(createTestLogger()); + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => { + const appServer = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { thread: { id: threadId, turns: threads[threadId] ?? [] } }; + }, + }); + appServers.push(appServer); + return appServer.child; + }), + }); const internals = castInternals<{ goalsEnabledPromise: Promise | null; autoReviewEnabledPromise: Promise | null; - spawnAppServer: () => Promise; }>(provider); internals.goalsEnabledPromise = Promise.resolve(false); internals.autoReviewEnabledPromise = Promise.resolve(false); - internals.spawnAppServer = async () => { - const appServer = createFakeCodexAppServer({ - "thread/read": (params) => { - const { threadId } = params as { threadId: string }; - return { thread: { id: threadId, turns: threads[threadId] ?? [] } }; - }, - }); - appServers.push(appServer); - return appServer.child; - }; + // The fake app-server stands in for the Codex CLI, which test machines may not have. const client: AgentClient = { provider: "codex", @@ -1351,7 +1359,7 @@ describe("Codex app-server provider", () => { const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession( createConfig({ thinkingOptionId: undefined }), ); - await firstSession.startTurn("first turn"); + const firstTurn = await firstSession.startTurn("first turn"); await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" }); const saved = waitForNextEvent(firstSession, "thread_started"); beforeRestart.startsTurn({ threadId: "thread-1" }); @@ -1370,7 +1378,8 @@ describe("Codex app-server provider", () => { await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "medium", }); - await resumed.startTurn("turn after restart"); + const resumedTurn = await resumed.startTurn("turn after restart"); + expect(resumedTurn.turnId).not.toBe(firstTurn.turnId); await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" }); beforeRestart.assertNoErrors(); afterRestart.assertNoErrors(); @@ -2373,10 +2382,9 @@ describe("Codex app-server provider", () => { return { thread: { id: "native-thread-id" } }; }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await provider.unarchiveNativeSession({ provider: "codex", @@ -2398,10 +2406,9 @@ describe("Codex app-server provider", () => { return { thread: { id: "native-thread-id" } }; }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await provider.archiveNativeSession({ provider: "codex", @@ -2423,10 +2430,9 @@ describe("Codex app-server provider", () => { return { thread: { id: "persisted-thread-id" } }; }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await provider.unarchiveNativeSession({ provider: "codex", @@ -2455,10 +2461,9 @@ describe("Codex app-server provider", () => { return { thread: { id: "active-thread-id", turns: [] } }; }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await provider.unarchiveNativeSession({ provider: "codex", @@ -2488,10 +2493,9 @@ describe("Codex app-server provider", () => { return Promise.reject(new Error("thread not found")); }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await expect( provider.unarchiveNativeSession({ @@ -4850,6 +4854,7 @@ describe("Codex app-server provider", () => { ); try { + const started = waitForNextEvent(session, "turn_started"); const resultPromise = session.run("Delegate the investigation."); await appServer.waitForTurnStart(); const child = waitForProviderSubagent(session, "legacy-only-child-thread"); @@ -4867,7 +4872,7 @@ describe("Codex app-server provider", () => { await expect(child).resolves.toMatchObject({ type: "provider_subagent", provider: "codex", - turnId: "codex-turn-0", + turnId: (await started).turnId, event: { type: "upsert", id: "legacy-only-child-thread", @@ -4877,7 +4882,7 @@ describe("Codex app-server provider", () => { await expect(spawn).resolves.toMatchObject({ type: "timeline", provider: "codex", - turnId: "codex-turn-0", + turnId: (await started).turnId, item: { type: "tool_call", callId: "spawn-legacy-only-child", @@ -7532,6 +7537,52 @@ describe("Codex app-server provider", () => { }); describe("Codex importable sessions", () => { + test("serializes concurrent app-server startups while allowing all scans to complete", async () => { + const firstEntered = deferred(); + const releaseFirst = deferred(); + let spawned = 0; + let activeInitializations = 0; + let maxActiveInitializations = 0; + let disposed = 0; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => { + spawned++; + return createCodexAppServerChildProcess(); + }), + _createCodexClient: () => ({ + request: async (method) => { + if (method === "initialize") { + activeInitializations++; + maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations); + if (spawned === 1) { + firstEntered.resolve(); + await releaseFirst.promise; + } + activeInitializations--; + return {}; + } + if (method === "thread/list") return { data: [] }; + throw new Error(`Unexpected Codex method: ${method}`); + }, + notify: () => {}, + dispose: async () => { + disposed++; + }, + }), + }); + + const scans = Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 })); + await firstEntered.promise; + expect(spawned).toBe(1); + releaseFirst.resolve(); + const results = await Promise.all(scans); + + expect(results).toEqual(Array.from({ length: 24 }, () => [])); + expect(spawned).toBe(24); + expect(maxActiveInitializations).toBe(1); + expect(disposed).toBe(24); + }); + const CODEX_THREAD_PAGE_CAP = 100; // Codex answers thread/list with at most 100 rows per response whatever limit @@ -7794,19 +7845,8 @@ describe("Codex importable sessions", () => { const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { _createCodexClient: () => fakeClient, + appServerProcess: createFakeCodexAppServerProcess(createCodexAppServerChildProcess), }); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => { - const child = new EventEmitter() as ChildProcessWithoutNullStreams; - child.exitCode = 0; - child.signalCode = null; - child.stdin = new PassThrough(); - child.stdout = new PassThrough(); - child.stderr = new PassThrough(); - child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"]; - return child; - }; const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index d9ffa528b1..3147d1c51f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -77,7 +77,7 @@ import { probeExecutable, } from "../../../executable-resolution/executable-resolution.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; -import { spawnProcess } from "../../../utils/spawn.js"; +import { spawnProcess, type SpawnProcessOptions } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexControls, @@ -96,6 +96,7 @@ import { type CodexThreadRollbackResponse, type CodexAppServerTraceContext, } from "./codex/app-server-transport.js"; +import { codexAppServerStartup } from "./codex/app-server-startup.js"; import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js"; import { type CodexHistoryRow, @@ -293,14 +294,31 @@ const CODEX_MODES: AgentMode[] = [ const DEFAULT_CODEX_MODE_ID = "auto"; interface CodexAppServerClientLike { - request(method: string, params?: unknown): Promise; + request(method: string, params?: unknown, timeoutMs?: number): Promise; forkThread?(params: CodexThreadForkParams): Promise; rollbackThread?(params: CodexThreadRollbackParams): Promise; notify(method: string, params?: unknown): void; dispose(): Promise; } +interface CodexAppServerLaunchPrefix { + command: string; + args: string[]; +} + +interface CodexAppServerProcessSpawnInput extends CodexAppServerLaunchPrefix { + options: SpawnProcessOptions; +} + +interface CodexAppServerProcessPort { + resolvePrefix(options: { + runtimeSettings?: ProviderRuntimeSettings; + }): Promise; + spawn(input: CodexAppServerProcessSpawnInput): ChildProcess; +} + interface CodexAppServerAgentDeps { + appServerProcess?: CodexAppServerProcessPort; environment?: Record; workspaceGitService?: Pick; customProvider?: { @@ -612,10 +630,9 @@ export async function findDefaultCodexBinary(): Promise { return await findCodexMicrosoftStoreBinary(); } -async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{ - command: string; - args: string[]; -}> { +async function resolveCodexLaunchPrefix( + runtimeSettings?: ProviderRuntimeSettings, +): Promise { const launch = await resolveCodexLaunch(runtimeSettings); const availability = await checkCodexLaunchAvailable(launch); if (!availability.available) { @@ -630,6 +647,11 @@ async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSetting }; } +const defaultCodexAppServerProcess: CodexAppServerProcessPort = { + resolvePrefix: ({ runtimeSettings }) => resolveCodexLaunchPrefix(runtimeSettings), + spawn: ({ command, args, options }) => spawnProcess(command, args, options), +}; + async function resolveCodexLaunch( runtimeSettings?: ProviderRuntimeSettings, ): Promise { @@ -3371,6 +3393,14 @@ export function buildCodexAppServerEnv( }); } +function codexStartupStateDirKey(environment: NodeJS.ProcessEnv): string { + const home = + process.platform === "win32" + ? environment.USERPROFILE || os.homedir() + : environment.HOME || os.homedir(); + return path.resolve(environment.CODEX_HOME || path.join(home, ".codex")); +} + function buildCodexAppServerInitializeParams(): { clientInfo: { name: string; title: string; version: string }; capabilities: { experimentalApi: true; mcpServerOpenaiFormElicitation: true }; @@ -3503,6 +3533,7 @@ export class CodexAppServerAgentSession implements AgentSession { private startupEvents: AgentStreamEvent[] | null = null; /** How the opened session differs from the options it was given; reported at first subscribe. */ openOptionsPatch: Record = {}; + private readonly turnNamespace = randomUUID(); private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; private activeClientMessageId: string | null = null; @@ -3571,6 +3602,7 @@ export class CodexAppServerAgentSession implements AgentSession { private unpairedCompactionItemCompletions = 0; private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected"; private connectionPromise: Promise | null = null; + private readonly startupAbort = new AbortController(); private closed = false; private collaborationModes: Array<{ name: string; @@ -3684,8 +3716,15 @@ export class CodexAppServerAgentSession implements AgentSession { this.connectionState = "history-ready"; return; } - const child = await this.spawnAppServer(); - const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); + const { client, initialized } = await codexAppServerStartup.start({ + stateDirKey: codexStartupStateDirKey(this.harnessEnvironment), + spawn: this.spawnAppServer, + createClient: (child) => + new CodexAppServerClient(child, this.logger, () => this.traceContext()), + initializeParams: buildCodexAppServerInitializeParams(), + logger: this.logger, + signal: this.startupAbort.signal, + }); if (this.closed) { await client.dispose(); throw this.createClosedError(); @@ -3698,11 +3737,9 @@ export class CodexAppServerAgentSession implements AgentSession { this.registerRequestHandlers(); try { - const initialized = toObjectRecord( - await client.request("initialize", buildCodexAppServerInitializeParams()), + this.threadRollbackAvailable = codexServerHasThreadRollback( + toObjectRecord(initialized)?.userAgent, ); - this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent); - client.notify("initialized", {}); this.speedModels = CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? []; @@ -4046,11 +4083,16 @@ export class CodexAppServerAgentSession implements AgentSession { } private async readArchivedHistory(): Promise { - const child = await this.spawnAppServer(); - const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); + const { client } = await codexAppServerStartup.start({ + stateDirKey: codexStartupStateDirKey(this.harnessEnvironment), + spawn: this.spawnAppServer, + createClient: (child) => + new CodexAppServerClient(child, this.logger, () => this.traceContext()), + initializeParams: buildCodexAppServerInitializeParams(), + logger: this.logger, + signal: this.startupAbort.signal, + }); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); - client.notify("initialized", {}); return await this.readThreadHistory(client); } finally { await client.dispose(); @@ -5349,6 +5391,7 @@ export class CodexAppServerAgentSession implements AgentSession { async close(): Promise { this.closed = true; + this.startupAbort.abort(this.createClosedError()); this.background.stop(); this.clearPendingPermissions(); this.pendingToolCallDetails.clear(); @@ -5730,7 +5773,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private createTurnId(): string { - return `codex-turn-${this.nextTurnOrdinal++}`; + return `codex-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`; } private pendingNotifications: Promise = Promise.resolve(); @@ -7587,31 +7630,55 @@ export class CodexAppServerAgentClient implements AgentClient { } } - private async spawnAppServer( - launchEnv?: Record, - options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record }, - ): Promise { - const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); + private async prepareAppServerSpawn(options: { + environment: Record; + goalsEnabled?: boolean; + agentId?: string; + }): Promise<() => ChildProcessWithoutNullStreams> { + const processPort = this.deps.appServerProcess ?? defaultCodexAppServerProcess; + const launchPrefix = await processPort.resolvePrefix({ runtimeSettings: this.runtimeSettings }); const args = [...launchPrefix.args, "app-server"]; - if (options?.goalsEnabled) { + if (options.goalsEnabled) { args.push("--enable", "goals"); } - this.logger.trace( - { - agentId: options?.agentId, - provider: CODEX_PROVIDER, - launchPrefix, - goalsEnabled: options?.goalsEnabled === true, - }, - "provider.codex.spawn", - ); - const child = spawnProcess(launchPrefix.command, args, { - detached: process.platform !== "win32", - stdio: ["pipe", "pipe", "pipe"], - baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv), + return () => { + this.logger.trace( + { + agentId: options.agentId, + provider: CODEX_PROVIDER, + launchPrefix, + goalsEnabled: options.goalsEnabled === true, + }, + "provider.codex.spawn", + ); + const child = processPort.spawn({ + command: launchPrefix.command, + args, + options: { + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + baseEnv: options.environment, + }, + }); + assertChildWithPipes(child); + return child; + }; + } + + private async startAppServer( + options: { signal?: AbortSignal } = {}, + ): Promise { + const environment = buildCodexAppServerEnv(this.runtimeSettings); + const spawn = await this.prepareAppServerSpawn({ environment }); + const { client } = await codexAppServerStartup.start({ + stateDirKey: codexStartupStateDirKey(environment), + spawn, + createClient: (child) => new CodexAppServerClient(child, this.logger), + initializeParams: buildCodexAppServerInitializeParams(), + logger: this.logger, + signal: options.signal, }); - assertChildWithPipes(child); - return child; + return client; } async createSession( @@ -7630,16 +7697,16 @@ export class CodexAppServerAgentClient implements AgentClient { const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); + const spawn = await this.prepareAppServerSpawn({ + environment, + goalsEnabled, + agentId: launchContext?.agentId, + }); const session = new CodexAppServerAgentSession( sessionConfig, null, this.logger, - () => - this.spawnAppServer(launchContext?.env, { - goalsEnabled, - agentId: launchContext?.agentId, - environment, - }), + async () => spawn(), { ...this.sessionDeps(launchContext?.env), environment }, options?.persistSession === false, goalsEnabled, @@ -7667,16 +7734,16 @@ export class CodexAppServerAgentClient implements AgentClient { const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); + const spawn = await this.prepareAppServerSpawn({ + environment, + goalsEnabled, + agentId: launchContext?.agentId, + }); const session = new CodexAppServerAgentSession( merged, handle, this.logger, - () => - this.spawnAppServer(launchContext?.env, { - goalsEnabled, - agentId: launchContext?.agentId, - environment, - }), + async () => spawn(), { ...this.sessionDeps(launchContext?.env), environment }, false, goalsEnabled, @@ -7691,15 +7758,19 @@ export class CodexAppServerAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const child = await this.spawnAppServer(); - const client = - this.deps._createCodexClient?.(child, this.logger, () => ({})) ?? - new CodexAppServerClient(child, this.logger); + const environment = buildCodexAppServerEnv(this.runtimeSettings); + const spawn = await this.prepareAppServerSpawn({ environment }); + const { client } = await codexAppServerStartup.start({ + stateDirKey: codexStartupStateDirKey(environment), + spawn, + createClient: (child) => + this.deps._createCodexClient?.(child, this.logger, () => ({})) ?? + new CodexAppServerClient(child, this.logger), + initializeParams: buildCodexAppServerInitializeParams(), + logger: this.logger, + }); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); - client.notify("initialized", {}); - const limit = options?.limit ?? 20; const scanLimit = Math.min(options?.scanLimit ?? limit, 500); // thread/list returns the cheap `cwd` field. Fetch a wider window when @@ -7802,15 +7873,10 @@ export class CodexAppServerAgentClient implements AgentClient { try { await runProviderRefreshActivity(context, "app-server.start", async () => { - const child = await this.spawnAppServer(); - client = new CodexAppServerClient(child, this.logger); + client = await this.startAppServer({ signal: context?.signal }); if (context?.signal.aborted) await dispose(); }); if (!client) throw new Error("Codex app-server did not start"); - await runProviderRefreshActivity(context, "initialize", () => - client!.request("initialize", buildCodexAppServerInitializeParams()), - ); - client.notify("initialized", {}); const rawResponse = await runProviderRefreshActivity(context, "model/list", () => client!.request("model/list", {}), @@ -7854,12 +7920,9 @@ export class CodexAppServerAgentClient implements AgentClient { const threadId = handle.nativeHandle ?? handle.sessionId; if (!threadId) return; - const child = await this.spawnAppServer(); - const client = new CodexAppServerClient(child, this.logger); + const client = await this.startAppServer(); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); - client.notify("initialized", {}); if (state === "archive") { await client.request("thread/archive", { threadId }); return; diff --git a/packages/server/src/server/agent/providers/codex/app-server-startup.test.ts b/packages/server/src/server/agent/providers/codex/app-server-startup.test.ts new file mode 100644 index 0000000000..9ff257343b --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/app-server-startup.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js"; +import { CodexAppServerStartupCoordinator } from "./app-server-startup.js"; +import { CodexAppServerExitError } from "./app-server-transport.js"; + +function sqliteStartupError(): CodexAppServerExitError { + return new CodexAppServerExitError({ + exitCode: 1, + exitSignal: null, + stderr: "failed to initialize sqlite state runtime", + }); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("Codex app-server startup coordinator", () => { + test("returns initialize metadata so sessions retain server capabilities", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const client = { + request: async () => ({ userAgent: "codex/0.164.0" }), + notify: () => {}, + dispose: async () => {}, + }; + const started = await coordinator.start({ + stateDirKey: "/codex/metadata", + spawn: createCodexAppServerChildProcess, + createClient: () => client, + initializeParams: {}, + logger: createTestLogger(), + }); + expect(started).toEqual({ client, initialized: { userAgent: "codex/0.164.0" } }); + }); + + test("serializes a burst through initialize but retains all initialized clients", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const firstEntered = deferred(); + const releaseFirst = deferred(); + let spawned = 0; + let activeInitializations = 0; + let maxActiveInitializations = 0; + let disposed = 0; + + const starts = Array.from({ length: 24 }, () => + coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + spawned++; + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async (method: string) => { + expect(method).toBe("initialize"); + activeInitializations++; + maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations); + if (spawned === 1) { + firstEntered.resolve(); + await releaseFirst.promise; + } + activeInitializations--; + return {}; + }, + notify: vi.fn(), + dispose: async () => { + disposed++; + }, + }), + initializeParams: {}, + logger: createTestLogger(), + }), + ); + + await firstEntered.promise; + expect(spawned).toBe(1); + releaseFirst.resolve(); + const clients = await Promise.all(starts); + + expect(clients).toHaveLength(24); + expect(spawned).toBe(24); + expect(maxActiveInitializations).toBe(1); + expect(disposed).toBe(0); + }); + + test("allows different Codex homes to initialize concurrently", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const bothEntered = deferred(); + const release = deferred(); + let entered = 0; + const start = (stateDirKey: string) => + coordinator.start({ + stateDirKey, + spawn: createCodexAppServerChildProcess, + createClient: () => ({ + request: async () => { + entered++; + if (entered === 2) bothEntered.resolve(); + await release.promise; + return {}; + }, + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + }); + + const first = start("/codex/one"); + const second = start("/codex/two"); + await bothEntered.promise; + expect(entered).toBe(2); + release.resolve(); + await Promise.all([first, second]); + }); + + test("retries only SQLite startup failures after disposing the failed child", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const events: string[] = []; + let attempt = 0; + const client = await coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + attempt++; + events.push(`spawn ${attempt}`); + return createCodexAppServerChildProcess(); + }, + createClient: () => { + const number = attempt; + return { + request: async () => { + events.push(`initialize ${number}`); + if (number === 1) throw sqliteStartupError(); + return {}; + }, + notify: () => events.push(`initialized ${number}`), + dispose: async () => { + events.push(`dispose ${number}`); + }, + }; + }, + initializeParams: {}, + logger: createTestLogger(), + }); + + expect(client.client).toEqual(expect.objectContaining({ request: expect.any(Function) })); + expect(events).toEqual([ + "spawn 1", + "initialize 1", + "dispose 1", + "spawn 2", + "initialize 2", + "initialized 2", + ]); + }); + + test("does not retry unrelated startup failures", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + let spawned = 0; + let disposed = 0; + await expect( + coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + spawned++; + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => { + throw new Error("database is locked while reading unrelated configuration"); + }, + notify: vi.fn(), + dispose: async () => { + disposed++; + }, + }), + initializeParams: {}, + logger: createTestLogger(), + }), + ).rejects.toThrow("database is locked while reading unrelated configuration"); + expect(spawned).toBe(1); + expect(disposed).toBe(1); + }); + + test("bounds persistent SQLite failures and leaves the lane usable", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + let spawned = 0; + let disposed = 0; + let locked = true; + const start = () => + coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + spawned++; + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => { + if (locked) throw sqliteStartupError(); + return {}; + }, + notify: vi.fn(), + dispose: async () => { + disposed++; + }, + }), + initializeParams: {}, + logger: createTestLogger(), + }); + + await expect(start()).rejects.toThrow("failed to initialize sqlite state runtime"); + expect(spawned).toBe(3); + expect(disposed).toBe(3); + locked = false; + await expect(start()).resolves.toEqual( + expect.objectContaining({ + client: expect.objectContaining({ request: expect.any(Function) }), + }), + ); + expect(spawned).toBe(4); + expect(disposed).toBe(3); + }); + + test("a cancelled waiter never spawns or overtakes the active startup", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const firstEntered = deferred(); + const releaseFirst = deferred(); + const events: string[] = []; + const first = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + events.push("spawn first"); + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => { + firstEntered.resolve(); + await releaseFirst.promise; + return {}; + }, + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + }); + await firstEntered.promise; + + const controller = new AbortController(); + const queued = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + events.push("spawn cancelled"); + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => ({}), + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + signal: controller.signal, + }); + controller.abort(new Error("cancelled")); + await expect(queued).rejects.toThrow("cancelled"); + const next = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + events.push("spawn next"); + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => ({}), + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + }); + + expect(events).toEqual(["spawn first"]); + releaseFirst.resolve(); + await Promise.all([first, next]); + expect(events).toEqual(["spawn first", "spawn next"]); + }); + + test("a timed-out initialize disposes its child and releases the next waiter", async () => { + vi.useFakeTimers(); + try { + const coordinator = new CodexAppServerStartupCoordinator({ + attemptTimeoutMs: 10, + queueTimeoutMs: 100, + }); + const firstEntered = deferred(); + const events: string[] = []; + const first = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: createCodexAppServerChildProcess, + createClient: () => ({ + request: () => { + firstEntered.resolve(); + return new Promise(() => {}); + }, + notify: vi.fn(), + dispose: async () => { + events.push("dispose first"); + }, + }), + initializeParams: {}, + logger: createTestLogger(), + }); + await firstEntered.promise; + const next = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + events.push("spawn next"); + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => ({}), + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + }); + + const firstResult = expect(first).rejects.toThrow("Timed out initializing Codex app-server"); + await vi.advanceTimersByTimeAsync(10); + await firstResult; + await expect(next).resolves.toEqual( + expect.objectContaining({ + client: expect.objectContaining({ request: expect.any(Function) }), + }), + ); + expect(events).toEqual(["dispose first", "spawn next"]); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/codex/app-server-startup.ts b/packages/server/src/server/agent/providers/codex/app-server-startup.ts new file mode 100644 index 0000000000..9453e9ffb9 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/app-server-startup.ts @@ -0,0 +1,189 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import type { Logger } from "pino"; + +import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import { CodexAppServerExitError } from "./app-server-transport.js"; + +const DEFAULT_QUEUE_TIMEOUT_MS = 60_000; +const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_ATTEMPTS = 3; + +interface InitializableAppServerClient { + request(method: string, params?: unknown, timeoutMs?: number): Promise; + notify(method: string, params?: unknown): void; + dispose(): Promise; +} + +interface StartupOptions { + stateDirKey: string; + spawn: () => ChildProcessWithoutNullStreams | Promise; + createClient: (child: ChildProcessWithoutNullStreams) => TClient; + initializeParams: unknown; + logger: Pick; + signal?: AbortSignal; +} + +interface StartedAppServer { + client: TClient; + initialized: unknown; +} + +interface CoordinatorOptions { + queueTimeoutMs?: number; + attemptTimeoutMs?: number; + maxAttempts?: number; +} + +async function waitForSignal(operation: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +async function disposeUnownedChild(child: ChildProcessWithoutNullStreams): Promise { + const result = await terminateWithTreeKill(child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 1_000, + }); + if (result === "kill-timeout") { + throw new Error("Codex app-server did not report exit after SIGKILL"); + } +} + +export class CodexAppServerStartupCoordinator { + private readonly tails = new Map>(); + private readonly queueTimeoutMs: number; + private readonly attemptTimeoutMs: number; + private readonly maxAttempts: number; + + constructor(options: CoordinatorOptions = {}) { + this.queueTimeoutMs = options.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS; + this.attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; + this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + } + + async start( + options: StartupOptions, + ): Promise> { + options.signal?.throwIfAborted(); + const previous = this.tails.get(options.stateDirKey) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + // An aborted waiter must not let later waiters pass the current owner. + const tail = previous.then(() => gate); + this.tails.set(options.stateDirKey, tail); + void tail.then( + () => this.tails.get(options.stateDirKey) === tail && this.tails.delete(options.stateDirKey), + ); + + const queueAbort = new AbortController(); + const onAbort = () => queueAbort.abort(options.signal?.reason); + options.signal?.addEventListener("abort", onAbort, { once: true }); + const queueTimer = setTimeout( + () => queueAbort.abort(new Error("Timed out waiting to start Codex app-server")), + this.queueTimeoutMs, + ); + try { + await waitForSignal(previous, queueAbort.signal); + clearTimeout(queueTimer); + options.signal?.throwIfAborted(); + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + try { + return await this.startAttempt(options); + } catch (error) { + const isRetryableStartupFailure = + error instanceof CodexAppServerExitError && + error.reason === "sqlite_state_initialization"; + if ( + options.signal?.aborted || + attempt === this.maxAttempts || + !isRetryableStartupFailure + ) { + throw error; + } + options.logger.warn( + { err: error, attempt, maxAttempts: this.maxAttempts }, + "Codex app-server SQLite startup failed; retrying with a fresh process", + ); + } + } + throw new Error("Codex app-server startup attempts exhausted"); + } finally { + clearTimeout(queueTimer); + options.signal?.removeEventListener("abort", onAbort); + release(); + } + } + + private async startAttempt( + options: StartupOptions, + ): Promise> { + const attemptAbort = new AbortController(); + const onAbort = () => attemptAbort.abort(options.signal?.reason); + options.signal?.addEventListener("abort", onAbort, { once: true }); + const attemptTimer = setTimeout( + () => attemptAbort.abort(new Error("Timed out initializing Codex app-server")), + this.attemptTimeoutMs, + ); + let child: ChildProcessWithoutNullStreams | undefined; + let client: TClient | undefined; + try { + attemptAbort.signal.throwIfAborted(); + // Production hands in a synchronous spawn closure. Resolve the binary + // before entering the lane, then create the child while holding it. + const spawnPromise = Promise.resolve(options.spawn()); + try { + child = await waitForSignal(spawnPromise, attemptAbort.signal); + } catch (error) { + if (attemptAbort.signal.aborted) { + // A future asynchronous spawner may finish after cancellation. + void spawnPromise + .then((lateChild) => disposeUnownedChild(lateChild)) + .catch((cleanupError) => + options.logger.warn({ err: cleanupError }, "Failed to clean up late Codex spawn"), + ); + } + throw error; + } + attemptAbort.signal.throwIfAborted(); + client = options.createClient(child); + const initialized = await waitForSignal( + client.request("initialize", options.initializeParams, this.attemptTimeoutMs), + attemptAbort.signal, + ); + attemptAbort.signal.throwIfAborted(); + client.notify("initialized", {}); + return { client, initialized }; + } catch (error) { + try { + if (client) { + await client.dispose(); + } else if (child) { + await disposeUnownedChild(child); + } + } catch (cleanupError) { + options.logger.warn( + { err: cleanupError, startupError: error }, + "Failed to clean up Codex app-server after startup failure", + ); + throw new Error("Codex app-server startup cleanup failed", { cause: cleanupError }); + } + throw error; + } finally { + clearTimeout(attemptTimer); + options.signal?.removeEventListener("abort", onAbort); + } + } +} + +export const codexAppServerStartup = new CodexAppServerStartupCoordinator(); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index 84b82b431b..859409ec5b 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -5,9 +5,57 @@ import { createCodexAppServerChildProcess, createFakeCodexAppServer, } from "./test-utils/fake-app-server.js"; -import { CodexAppServerClient } from "./app-server-transport.js"; +import { CodexAppServerClient, CodexAppServerExitError } from "./app-server-transport.js"; describe("Codex app-server transport", () => { + test("classifies startup diagnostics delivered after the child exits", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + const request = client.request("initialize", {}); + const result = expect(request).rejects.toMatchObject({ + name: "CodexAppServerExitError", + reason: "sqlite_state_initialization", + stderr: "failed to initialize sqlite state runtime", + }); + child.exitCode = 1; + child.emit("exit", 1, null); + child.stderr.write("failed to initialize sqlite state runtime"); + child.emit("close", 1, null); + try { + await result; + } finally { + child.stdout.end(); + child.stderr.end(); + child.stdin.end(); + } + }); + + test.each([ + { + stderr: "Error: failed to initialize sqlite state runtime under /isolated/.codex", + reason: "sqlite_state_initialization", + }, + { stderr: "database is locked while loading configuration", reason: "other" }, + ] as const)("classifies a failed startup from child stderr as $reason", async (entry) => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + const request = client.request("initialize", {}); + child.stderr.write(entry.stderr); + child.exitCode = 1; + child.emit("exit", 1, null); + + await expect(request).rejects.toMatchObject({ + name: "CodexAppServerExitError", + reason: entry.reason, + exitCode: 1, + exitSignal: null, + stderr: entry.stderr, + } satisfies Partial); + child.stdout.end(); + child.stderr.end(); + child.stdin.end(); + }); + test("ignores non-JSON stdout lines without dropping pending requests", async () => { const child = createCodexAppServerChildProcess(); const client = new CodexAppServerClient(child, createTestLogger()); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index 5a218e2edb..04793e011a 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -64,6 +64,39 @@ export class CodexAppServerRpcError extends Error { } } +interface CodexAppServerExitDetails { + exitCode: number | null; + exitSignal: NodeJS.Signals | null; + stderr: string; +} + +export class CodexAppServerExitError extends Error { + readonly reason: "sqlite_state_initialization" | "other"; + readonly exitCode: number | null; + readonly exitSignal: NodeJS.Signals | null; + readonly stderr: string; + + constructor(details: CodexAppServerExitDetails) { + const message = + details.exitCode === 0 && !details.exitSignal + ? "Codex app-server exited" + : `Codex app-server exited with code ${details.exitCode ?? "null"} and signal ${details.exitSignal ?? "null"}`; + super(`${message}\n${details.stderr}`.trim()); + this.name = "CodexAppServerExitError"; + this.exitCode = details.exitCode; + this.exitSignal = details.exitSignal; + this.stderr = details.stderr; + // Codex reports this pre-initialize failure only on stderr, not as a + // JSON-RPC error. Classify it here so callers branch on a typed reason. + this.reason = + details.exitCode === 1 && + !details.exitSignal && + details.stderr.includes("failed to initialize sqlite state runtime") + ? "sqlite_state_initialization" + : "other"; + } +} + type RequestHandler = (params: unknown, requestId: number) => unknown; type NotificationHandler = (method: string, params: unknown) => void; type UnexpectedTerminationHandler = (error: Error) => void; @@ -234,14 +267,24 @@ export class CodexAppServerClient { this.handleUnexpectedTermination(err); }); + const reportExit = (code: number | null, signal: NodeJS.Signals | null) => { + this.handleUnexpectedTermination( + new CodexAppServerExitError({ + exitCode: code, + exitSignal: signal, + stderr: this.stderrBuffer, + }), + ); + }; child.on("exit", (code, signal) => { - const message = - code === 0 && !signal - ? "Codex app-server exited" - : `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`; - const error = new Error(`${message}\n${this.stderrBuffer}`.trim()); - this.handleUnexpectedTermination(error); + // Startup retries need complete diagnostics. Node drains the child's + // stdio before close, but exit can precede the final stderr data. + const initializing = Array.from(this.pending.values()).some( + (request) => request.method === "initialize", + ); + if (!initializing) reportExit(code, signal); }); + child.on("close", reportExit); } setUnexpectedTerminationHandler(handler: UnexpectedTerminationHandler): void { diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 106c734753..195d924654 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -146,6 +146,10 @@ export function createCodexAppServerChildProcess(): CodexAppServerChildProcess { exitCode: null, signalCode: null, }) as CodexAppServerChildProcess; + // A real ChildProcess closes its stdio after exit, then emits close. + child.once("exit", (code, signal) => { + queueMicrotask(() => child.emit("close", code, signal)); + }); child.kill = ((signal?: NodeJS.Signals | number) => { queueMicrotask(() => child.emit("exit", null, signal ?? null)); return true; diff --git a/packages/server/src/server/agent/providers/codex/thread-history.test.ts b/packages/server/src/server/agent/providers/codex/thread-history.test.ts index 8d3d8fce3d..3feb737aa8 100644 --- a/packages/server/src/server/agent/providers/codex/thread-history.test.ts +++ b/packages/server/src/server/agent/providers/codex/thread-history.test.ts @@ -1,4 +1,3 @@ -import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; @@ -27,15 +26,24 @@ async function openCodexThread( const appServer = createFakeCodexAppServer( fakeCodexThreadHistory({ "saved-thread": thread }, options), ); - const provider = new CodexAppServerAgentClient(createTestLogger()); + const provider = new CodexAppServerAgentClient( + createTestLogger(), + { + command: { mode: "replace", argv: ["codex-must-not-run-in-this-test"] }, + }, + { + appServerProcess: { + resolvePrefix: async () => ({ command: "codex", args: [] }), + spawn: () => appServer.child, + }, + }, + ); const internals = provider as unknown as { goalsEnabledPromise: Promise | null; autoReviewEnabledPromise: Promise | null; - spawnAppServer: () => Promise; }; internals.goalsEnabledPromise = Promise.resolve(false); internals.autoReviewEnabledPromise = Promise.resolve(false); - internals.spawnAppServer = async () => appServer.child; const session = await provider.resumeSession({ sessionId: "saved-thread" }); return { appServer, session }; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 01c8b7fe5a..8b8b0d491d 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -32,6 +32,16 @@ import type { const EXPECTED_STARTUP_BUDGET_MS = 30_000; const EXPECTED_EVENT_READINESS_BUDGET_MS = 45_000; +// Keep event correlation and turn ordering assertions exact while accepting a fresh namespace. +function turnIdAt(events: AgentStreamEvent[], ordinal: number): string { + const started = events.find((event) => event.type === "turn_started"); + if (!started || started.type !== "turn_started" || typeof started.turnId !== "string") { + throw new Error("Expected a turn_started event with a turn ID"); + } + const namespace = started.turnId.slice(0, started.turnId.lastIndexOf("-")); + return `${namespace}-${ordinal}`; +} + function tmpCwd(): string { const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-")); try { @@ -757,6 +767,19 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { } }); + test("keeps turn identities distinct for the same persisted session", async () => { + const first = await createParentSession("ses_persisted"); + const firstTurn = await first.parent.startTurn("first"); + await first.parent.close(); + const resumed = await createParentSession("ses_persisted"); + try { + const nextTurn = await resumed.parent.startTurn("second"); + expect(nextTurn.turnId).not.toBe(firstTurn.turnId); + } finally { + await resumed.parent.close(); + } + }); + test.each([ { saved: "default", variant: undefined }, { saved: "high", variant: "high" }, @@ -1638,10 +1661,10 @@ describe("OpenCode adapter startTurn error handling", () => { item: { type: "user_message", clientMessageId: "client-message-1" }, }); expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([ - "opencode-turn-0", - "opencode-turn-0", - "opencode-turn-0", - "opencode-turn-0", + turnIdAt(turn.events, 0), + turnIdAt(turn.events, 0), + turnIdAt(turn.events, 0), + turnIdAt(turn.events, 0), ]); }); @@ -1833,7 +1856,7 @@ describe("OpenCode adapter startTurn error handling", () => { type: "error", message: "Provider retry (attempt 1): model does not exist", }, - turnId: "opencode-turn-0", + turnId: turnIdAt(events, 0), }); expect(events.some((event) => event.type === "turn_failed")).toBe(false); await session.close(); @@ -2641,11 +2664,11 @@ describe("OpenCode adapter startTurn error handling", () => { expect( turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")), ).toEqual([ - ["turn_started", "opencode-turn-0"], - ["turn_canceled", "opencode-turn-0"], - ["turn_started", "opencode-turn-1"], - ["timeline", "opencode-turn-1"], - ["turn_completed", "opencode-turn-1"], + ["turn_started", turnIdAt(events, 0)], + ["turn_canceled", turnIdAt(events, 0)], + ["turn_started", turnIdAt(events, 1)], + ["timeline", turnIdAt(events, 1)], + ["turn_completed", turnIdAt(events, 1)], ]); } finally { settleRetry.resolve(); @@ -2706,7 +2729,7 @@ describe("OpenCode adapter startTurn error handling", () => { expect(events).toContainEqual( expect.objectContaining({ type: "timeline", - turnId: "opencode-turn-1", + turnId: turnIdAt(events, 1), item: expect.objectContaining({ text: "Wake output streams live." }), }), ), @@ -2726,11 +2749,11 @@ describe("OpenCode adapter startTurn error handling", () => { }); await vi.waitFor(() => expect(events).toContainEqual( - expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }), + expect.objectContaining({ type: "turn_completed", turnId: turnIdAt(events, 1) }), ), ); await expect(session.startTurn("after the wake")).resolves.toEqual({ - turnId: "opencode-turn-2", + turnId: turnIdAt(events, 2), }); } finally { settleAbort.resolve(); @@ -2843,13 +2866,13 @@ describe("OpenCode adapter startTurn error handling", () => { expect(events).toContainEqual({ type: "turn_started", provider: "opencode", - turnId: "opencode-turn-1", + turnId: turnIdAt(events, 1), }); await vi.waitFor(() => expect(events).toContainEqual({ type: "turn_completed", provider: "opencode", - turnId: "opencode-turn-1", + turnId: turnIdAt(events, 1), usage: expect.objectContaining({ inputTokens: 7, outputTokens: 3, @@ -2858,7 +2881,7 @@ describe("OpenCode adapter startTurn error handling", () => { }), ); const autonomousCompletion = events.find( - (event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1", + (event) => event.type === "turn_completed" && event.turnId === turnIdAt(events, 1), ); expect(autonomousCompletion?.type).toBe("turn_completed"); if (autonomousCompletion?.type === "turn_completed") { @@ -2867,7 +2890,7 @@ describe("OpenCode adapter startTurn error handling", () => { expect(events).toContainEqual( expect.objectContaining({ type: "timeline", - turnId: "opencode-turn-1", + turnId: turnIdAt(events, 1), item: expect.objectContaining({ type: "assistant_message", text: "Autonomous wake completed.", @@ -2875,7 +2898,7 @@ describe("OpenCode adapter startTurn error handling", () => { }), ); await expect(session.startTurn("after autonomous wake")).resolves.toEqual({ - turnId: "opencode-turn-2", + turnId: turnIdAt(events, 2), }); } finally { settleAbort.resolve(); @@ -2954,7 +2977,7 @@ describe("OpenCode adapter startTurn error handling", () => { type: "turn_canceled", provider: "opencode", reason: "interrupted", - turnId: "opencode-turn-0", + turnId: turnIdAt(events, 0), }); openCode.emitEvent({ @@ -2962,7 +2985,7 @@ describe("OpenCode adapter startTurn error handling", () => { properties: { sessionID: "ses_retried_stop" }, }); await expect(session.startTurn("replacement")).resolves.toEqual({ - turnId: "opencode-turn-1", + turnId: turnIdAt(events, 1), }); expect(openCode.calls.sessionPromptAsync).toHaveLength(2); } finally { @@ -3058,7 +3081,7 @@ describe("OpenCode adapter startTurn error handling", () => { expect(events).toContainEqual({ type: "turn_started", provider: "opencode", - turnId: "opencode-turn-1", + turnId: turnIdAt(events, 1), }), ); @@ -3767,7 +3790,7 @@ describe("OpenCode adapter startTurn error handling", () => { ]; const replacement = session.startTurn("second"); await vi.advanceTimersByTimeAsync(10_000); - await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" }); + await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) }); expect(countEvents(events, "turn_canceled")).toBe(1); } finally { vi.useRealTimers(); @@ -4009,6 +4032,9 @@ describe("OpenCode adapter startTurn error handling", () => { return { data: true }; }; + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { await session.startTurn("first"); const interrupt = session.interrupt(); @@ -4022,7 +4048,7 @@ describe("OpenCode adapter startTurn error handling", () => { expect(openCode.calls.sessionStatus).toHaveLength(1); releaseIdle.resolve(); - await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" }); + await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) }); expect(openCode.calls.sessionPromptAsync).toHaveLength(2); expect(openCode.calls.sessionStatus.length).toBeGreaterThanOrEqual(2); } finally { @@ -4722,9 +4748,9 @@ describe("OpenCode provider subagent contract", () => { "turn_completed", ]); expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([ - "opencode-turn-0", - "opencode-turn-0", - "opencode-turn-0", + turnIdAt(events, 0), + turnIdAt(events, 0), + turnIdAt(events, 0), ]); expect(events).toContainEqual( expect.objectContaining({ @@ -4760,7 +4786,7 @@ describe("OpenCode provider subagent contract", () => { expect(events).toContainEqual({ type: "turn_started", provider: "opencode", - turnId: "opencode-turn-0", + turnId: turnIdAt(events, 0), }); }); for (const event of assistantTurnEvents({ @@ -4774,9 +4800,9 @@ describe("OpenCode provider subagent contract", () => { expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]); expect(turnEventSignatures(events)).toEqual([ - ["turn_started", "opencode-turn-0"], - ["timeline", "opencode-turn-0"], - ["turn_completed", "opencode-turn-0"], + ["turn_started", turnIdAt(events, 0)], + ["timeline", turnIdAt(events, 0)], + ["turn_completed", turnIdAt(events, 0)], ]); expect(events).toContainEqual( expect.objectContaining({ @@ -4881,9 +4907,9 @@ describe("OpenCode provider subagent contract", () => { await vi.waitFor(() => { expect(turnEventSignatures(events)).toEqual([ - ["turn_started", "opencode-turn-0"], - ["timeline", "opencode-turn-0"], - ["turn_completed", "opencode-turn-0"], + ["turn_started", turnIdAt(events, 0)], + ["timeline", turnIdAt(events, 0)], + ["turn_completed", turnIdAt(events, 0)], ]); }); expect(events).toContainEqual( @@ -4927,9 +4953,9 @@ describe("OpenCode provider subagent contract", () => { await vi.waitFor(() => { expect(turnEventSignatures(events)).toEqual([ - ["turn_started", "opencode-turn-0"], - ["timeline", "opencode-turn-0"], - ["turn_completed", "opencode-turn-0"], + ["turn_started", turnIdAt(events, 0)], + ["timeline", turnIdAt(events, 0)], + ["turn_completed", turnIdAt(events, 0)], ]); }); } finally { @@ -5003,7 +5029,7 @@ describe("OpenCode provider subagent contract", () => { }); await vi.waitFor(() => { expect(events).toEqual([ - { type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" }, + { type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) }, ]); }); @@ -5118,12 +5144,12 @@ describe("OpenCode provider subagent contract", () => { await streamDrained.promise; expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([ - { type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" }, + { type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) }, { type: "turn_canceled", provider: "opencode", reason: "interrupted", - turnId: "opencode-turn-0", + turnId: turnIdAt(events, 0), }, ]); } finally { @@ -5174,7 +5200,7 @@ describe("OpenCode provider subagent contract", () => { expect.objectContaining({ type: "permission_requested", request: expect.objectContaining({ id: "perm_child_external" }), - turnId: "opencode-turn-0", + turnId: turnIdAt(events, 0), }), ); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 8549caeccf..85c32e978f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -3415,6 +3415,7 @@ class OpenCodeAgentSession implements AgentSession { private partTypes = new Map(); private availableModesCache: AgentMode[] | null = null; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private readonly turnNamespace = randomUUID(); private nextTurnOrdinal = 0; private turnState: OpenCodeTurnState = { status: "idle" }; /** @@ -4894,7 +4895,7 @@ class OpenCodeAgentSession implements AgentSession { private createTurnId(): string { this.gapRepairRevision += 1; - return `opencode-turn-${this.nextTurnOrdinal++}`; + return `opencode-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`; } private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void { diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index b4abb3b77f..f214260769 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -197,6 +197,8 @@ const INBOUND_PERMISSION = { stash_pop_request: "workspace.write", stash_save_request: "workspace.write", "checkout.diff.get.request": "workspace.read", + "diff.subscribe.request": "workspace.read", + "diff.unsubscribe.request": "workspace.read", subscribe_checkout_diff_request: "workspace.read", subscribe_terminal_request: "workspace.read", subscribe_terminals_request: "workspace.read", @@ -447,6 +449,7 @@ const OUTBOUND_PERMISSION = { stash_save_response: "workspace.read", status: "daemon.read", "checkout.diff.get.response": "workspace.read", + "diff.subscribe.response": "workspace.read", subscribe_checkout_diff_response: "workspace.read", subscribe_terminal_response: "workspace.read", "terminal.rename.response": "workspace.write", diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index d55171831b..da2947c24d 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,488 +1,253 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { CheckoutDiffManager, type CheckoutDiffSnapshotPayload } from "./checkout-diff-manager.js"; import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createFileObserver } from "./file-observer/index.js"; -const { toCheckoutErrorMock } = vi.hoisted(() => ({ - toCheckoutErrorMock: vi.fn((error: unknown) => ({ - message: error instanceof Error ? error.message : String(error), - })), -})); - -vi.mock("./checkout-git-utils.js", () => ({ - toCheckoutError: toCheckoutErrorMock, -})); - -import type pino from "pino"; -import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; - -interface Deferred { - promise: Promise; - resolve(value: T): void; -} - -function createDeferred(): Deferred { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -function createWorkspaceSnapshot( - overrides?: Partial, -): WorkspaceGitRuntimeSnapshot { - return { - cwd: "/tmp/repo", - git: { - isGit: true, - repoRoot: "/tmp/repo", - mainRepoRoot: null, - currentBranch: "feature", - remoteUrl: "https://github.com/acme/repo.git", - isPaseoOwnedWorktree: false, - isDirty: false, - baseRef: "main", - aheadBehind: { ahead: 1, behind: 0 }, - aheadOfOrigin: 1, - behindOfOrigin: 0, - hasRemote: true, - diffStat: { additions: 1, deletions: 0 }, - ...overrides, - }, - forge: { - featuresEnabled: false, - authState: "no_remote", - pullRequest: null, - error: null, - }, - }; -} - -function createPendingManager() { - const watches: Array<{ +async function withCheckout( + run: (input: { cwd: string; - onChange: () => void; - unsubscribeCalls: number; - resolve(): void; - }> = []; - const workspaceGitService = { - getCheckoutDiff: async () => ({ diff: "", structured: [] }), - getSnapshot: async () => createWorkspaceSnapshot(), - - registerWorkspace: () => ({ unsubscribe: () => {} }), - requestWorkingTreeWatch: (cwd: string, onChange: () => void) => { - const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>(); - const watch = { - cwd, - onChange, - unsubscribeCalls: 0, - resolve: () => { - pending.resolve({ - repoRoot: "/tmp/repo", - unsubscribe: () => { - watch.unsubscribeCalls += 1; - }, - }); - }, - }; - watches.push(watch); - return pending.promise; - }, - }; - const logger = { child: () => logger, warn: () => {} }; + manager: CheckoutDiffManager; + git(...args: string[]): string; + }) => Promise, +): Promise { + const cwd = await mkdtemp(join(tmpdir(), "legacy-diff-adapter-")); + const observer = createFileObserver(); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); const manager = new CheckoutDiffManager({ + logger: pino({ level: "silent" }), + paseoHome: cwd, workspaceRuntimeStore: createWorkspaceRuntimeStore(), - logger: logger as unknown as pino.Logger, - paseoHome: "/tmp/paseo-test", - workspaceGitService, + workspaceGitService: { + async requestWorkingTreeWatch(root, onChange) { + const subscription = await observer.subscribe(root, () => onChange(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await subscription.unsubscribe(); + }, + }; + }, + }, }); - return { manager, watches }; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await run({ cwd, manager, git }); + } finally { + await manager.dispose(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } } -describe("CheckoutDiffManager", () => { - beforeEach(() => { - vi.useFakeTimers(); - toCheckoutErrorMock.mockClear(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - function createManager(options?: { - repoRoot?: string | null; - getCheckoutDiffImplementation?: ReturnType; - }) { - const unsubscribe = vi.fn(); - const workspaceUnsubscribe = vi.fn(); - let onChange: (() => void) | null = null; - let onWorkspaceSnapshot: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null; - const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => { - onChange = listener; - return { - repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot, - unsubscribe, - }; - }); - - const workspaceGitService = { - subscribe: vi.fn(), - - registerWorkspace: vi.fn( - (_params: { cwd: string }, listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) => { - onWorkspaceSnapshot = listener; - return { unsubscribe: workspaceUnsubscribe }; - }, +test("legacy callers share one live comparison, receive edits, and release it", async () => { + await withCheckout(async ({ cwd, manager }) => { + await mkdir(join(cwd, "sub")); + await writeFile(join(cwd, "x.ts"), "const value = 1;\n"); + const first: CheckoutDiffSnapshotPayload[] = []; + const second: CheckoutDiffSnapshotPayload[] = []; + const [a, b] = await Promise.all([ + manager.subscribe({ cwd: join(cwd, "sub"), compare: { mode: "uncommitted" } }, (snapshot) => + first.push(snapshot), ), - getSnapshot: vi.fn(async () => createWorkspaceSnapshot()), - getCheckoutDiff: - options?.getCheckoutDiffImplementation ?? vi.fn(async () => ({ diff: "", structured: [] })), - refresh: vi.fn(), - scheduleRefreshForCwd: vi.fn(), - requestWorkingTreeWatch: mockRequestWorkingTreeWatch, - dispose: vi.fn(), - }; - - const logger = { - child: () => logger, - warn: vi.fn(), - }; - - const manager = new CheckoutDiffManager({ - workspaceRuntimeStore: createWorkspaceRuntimeStore(), - logger: logger as unknown as pino.Logger, - paseoHome: "/tmp/paseo-test", - workspaceGitService: workspaceGitService as unknown as WorkspaceGitService, + manager.subscribe({ cwd, compare: { mode: "uncommitted" } }, (snapshot) => + second.push(snapshot), + ), + ]); + expect(a.initial.cwd).toBe(join(cwd, "sub")); + expect(a.initial.files).toEqual(b.initial.files); + expect(a.initial.files.map((file) => file.path)).toEqual(["x.ts"]); + expect(manager.getMetrics()).toMatchObject({ + checkoutDiffTargetCount: 1, + checkoutDiffSubscriptionCount: 2, }); + await writeFile(join(cwd, "x.ts"), "const value = 2;\n"); + await expect.poll(() => first.length, { timeout: 10000 }).toBeGreaterThan(0); + expect(first.at(-1)?.files).toEqual(second.at(-1)?.files); + await a.unsubscribe(); + await b.unsubscribe(); + expect(manager.getMetrics().checkoutDiffTargetCount).toBe(0); + }); +}, 30_000); - return { - manager, - workspaceGitService, - mockRequestWorkingTreeWatch, - unsubscribe, - getOnChange: () => onChange, - getOnWorkspaceSnapshot: () => onWorkspaceSnapshot, - workspaceUnsubscribe, - }; - } - - test("subscribe requests a working tree watch with the correct cwd", async () => { - const { manager, mockRequestWorkingTreeWatch } = createManager(); - - await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - }, - () => {}, +test("Committed ignores working-tree edits while All and Uncommitted include them", async () => { + await withCheckout(async ({ cwd, manager, git }) => { + git("checkout", "-b", "feature"); + await writeFile(join(cwd, "x.ts"), "const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "feature"); + await writeFile(join(cwd, "x.ts"), "const value = 2;\n"); + const committed = await manager.read({ cwd, compare: { mode: "base", baseRef: "main" } }); + const all = await manager.read({ cwd, compare: { mode: "all", baseRef: "main" } }); + const uncommitted = await manager.read({ cwd, compare: { mode: "uncommitted" } }); + expect(committed.files[0].hunks[0].lines).toContainEqual( + expect.objectContaining({ type: "add", content: "const value = 1;" }), ); - - expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith( - "/tmp/repo/packages/server", - expect.any(Function), + expect(all.files[0].hunks[0].lines).toContainEqual( + expect.objectContaining({ type: "add", content: "const value = 2;" }), + ); + expect(uncommitted.files[0].hunks[0].lines).toContainEqual( + expect.objectContaining({ type: "remove", content: "const value = 1;" }), ); }); +}, 30_000); - test("unsubscribe calls the working tree watch unsubscribe", async () => { - const { manager, unsubscribe } = createManager(); - - const subscription = await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - }, - () => {}, - ); - - subscription.unsubscribe(); - - expect(unsubscribe).toHaveBeenCalledTimes(1); - }); - - test("cancels a subscription while its working tree watch is still opening", async () => { - const { manager, watches } = createPendingManager(); +test("cancelling a legacy subscriber during a shared revision does not revive it", async () => { + await withCheckout(async ({ cwd, manager }) => { + await writeFile(join(cwd, "x.ts"), "export const value = 1;\n"); const abort = new AbortController(); - - const pendingSubscription = manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - signal: abort.signal, + let cancelled = false; + const modern = await manager.streams.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } }, + async (frame) => { + if (frame.opcode !== 0x40 || cancelled) return; + await waitForSubscriberCount(manager, 2); + cancelled = true; + abort.abort(); }, - () => {}, ); - abort.abort(); - watches[0].resolve(); - await pendingSubscription; - - expect(watches[0].unsubscribeCalls).toBe(1); - expect(manager.getMetrics()).toEqual({ - checkoutDiffTargetCount: 0, - checkoutDiffSubscriptionCount: 0, - checkoutDiffWatcherCount: 0, - checkoutDiffFallbackRefreshTargetCount: 0, - }); - }); - - test("shares one opening target between concurrent subscriptions", async () => { - const { manager, watches } = createPendingManager(); - - const firstSubscription = manager.subscribe( - { cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } }, - () => {}, - ); - const secondSubscription = manager.subscribe( - { cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } }, - () => {}, - ); - - expect(watches).toHaveLength(1); - watches[0].resolve(); - const [first, second] = await Promise.all([firstSubscription, secondSubscription]); - expect(manager.getMetrics().checkoutDiffSubscriptionCount).toBe(2); - - first.unsubscribe(); - expect(watches[0].unsubscribeCalls).toBe(0); - second.unsubscribe(); - expect(watches[0].unsubscribeCalls).toBe(1); - }); - - test("diffCwd uses repoRoot from the working tree watch result", async () => { - const { manager, workspaceGitService } = createManager({ repoRoot: "/tmp/repo" }); - - await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, + const legacy = manager.subscribe( + { cwd, compare: { mode: "uncommitted" }, signal: abort.signal }, + () => { + throw new Error("Cancelled subscriber received an update"); }, - () => {}, ); - - expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith( - "/tmp/repo", - expect.objectContaining({ mode: "uncommitted", includeStructured: true }), - undefined, - ); - }); - - test("diff refresh is triggered when the working tree watch callback fires", async () => { - const getCheckoutDiff = vi - .fn() - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }], - }) - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], + try { + await modern.start(); + const result = await legacy; + expect(result.initial).toMatchObject({ + files: [], + error: { message: "Diff subscription cancelled" }, }); - - const { manager, getOnChange } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, - }); - const listener = vi.fn(); - - await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - }, - listener, - ); - - const onChange = getOnChange(); - expect(onChange).toBeTypeOf("function"); - - onChange?.(); - await vi.advanceTimersByTimeAsync(150); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ - cwd: "/tmp/repo/packages/server", - files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], - error: null, - }); - }); - - test("watch-triggered refresh forces a cache bypass on getCheckoutDiff", async () => { - const getCheckoutDiff = vi - .fn() - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }], - }) - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], - }); - - const { manager, getOnChange } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, - }); - - await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - }, - vi.fn(), - ); - - expect(getCheckoutDiff).toHaveBeenNthCalledWith( - 1, - "/tmp/repo", - expect.objectContaining({ mode: "uncommitted" }), - undefined, - ); - - const onChange = getOnChange(); - onChange?.(); - await vi.advanceTimersByTimeAsync(150); - - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - const watchFiredCall = getCheckoutDiff.mock.calls[1]; - expect(watchFiredCall[2]).toEqual({ - force: true, - reason: expect.stringContaining("working-tree"), - }); - }); - - test("an edit during an in-flight diff refresh produces one final follow-up", async () => { - const inFlightDiff = createDeferred<{ - diff: string; - structured: Array<{ - path: string; - additions: number; - deletions: number; - status: "modified"; - }>; - }>(); - const getCheckoutDiff = vi - .fn() - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "tracked.ts", additions: 1, deletions: 0, status: "modified" }], - }) - .mockImplementationOnce(() => inFlightDiff.promise) - .mockResolvedValue({ - diff: "", - structured: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }], - }); - const { manager, getOnChange } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, - }); - const listener = vi.fn(); - - await manager.subscribe({ cwd: "/tmp/repo", compare: { mode: "uncommitted" } }, listener); - getOnChange()?.(); - await vi.advanceTimersByTimeAsync(150); - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - - for (let event = 0; event < 100; event += 1) { - getOnChange()?.(); + await result.unsubscribe(); + } finally { + await modern.release(); } - await vi.advanceTimersByTimeAsync(150); - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - - inFlightDiff.resolve({ - diff: "", - structured: [{ path: "tracked.ts", additions: 2, deletions: 1, status: "modified" }], - }); - await vi.waitFor(() => { - expect(getCheckoutDiff).toHaveBeenCalledTimes(3); - expect(listener).toHaveBeenLastCalledWith({ - cwd: "/tmp/repo", - files: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }], - error: null, - }); - }); }); +}, 30_000); - test("base diff subscriptions ignore ordinary working tree edits", async () => { - const getCheckoutDiff = vi.fn(async () => ({ - diff: "", - structured: [{ path: "committed.ts", additions: 1, deletions: 0, status: "modified" }], - })); - const { manager, getOnChange, mockRequestWorkingTreeWatch } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, +async function waitForSubscriberCount(manager: CheckoutDiffManager, count: number): Promise { + await expect + .poll(() => manager.getMetrics().checkoutDiffSubscriptionCount, { timeout: 10_000 }) + .toBe(count); +} + +test("moving a tag refreshes binary file metadata even though binary files have no bodies", async () => { + await withCheckout(async ({ cwd, manager, git }) => { + git("tag", "v1"); + await writeFile(join(cwd, "image.bin"), Buffer.from([0, 1])); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "binary"); + await writeFile(join(cwd, "image.bin"), Buffer.from([0, 2])); + const updates: CheckoutDiffSnapshotPayload[] = []; + const subscription = await manager.subscribe( + { cwd, compare: { mode: "all", baseRef: "v1" } }, + (snapshot) => updates.push(snapshot), + ); + expect(subscription.initial.files[0]).toMatchObject({ + path: "image.bin", + isNew: true, + status: "binary", }); - - await manager.subscribe( - { cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } }, - vi.fn(), - ); - getOnChange()?.(); - await vi.advanceTimersByTimeAsync(150); - - expect(getCheckoutDiff).toHaveBeenCalledTimes(1); - expect(mockRequestWorkingTreeWatch).not.toHaveBeenCalled(); + git("update-ref", "refs/tags/v1", "HEAD"); + await expect.poll(() => updates.at(-1)?.files[0]?.isNew, { timeout: 10000 }).toBe(false); + await subscription.unsubscribe(); }); +}, 30000); - test("base diff subscriptions ignore worktree-only workspace snapshot updates", async () => { - const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] })); - const { manager, getOnWorkspaceSnapshot } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, +test("legacy diffs retain successful bodies when an oversized sibling is removed", async () => { + await withCheckout(async ({ cwd, manager }) => { + await writeFile(join(cwd, "a.ts"), "const value = 1;\n"); + await writeFile(join(cwd, "b.txt"), "x".repeat(1024 * 1024 + 1)); + const updates: CheckoutDiffSnapshotPayload[] = []; + const subscription = await manager.subscribe( + { cwd, compare: { mode: "uncommitted" } }, + (snapshot) => updates.push(snapshot), + ); + expect(subscription.initial.files).toMatchObject([ + { path: "a.ts", status: "ok" }, + { path: "b.txt", status: "too_large" }, + ]); + await rm(join(cwd, "b.txt")); + await manager.scheduleRefreshForCwd(cwd); + await expect.poll(() => updates.at(-1)?.error).toBeNull(); + expect(updates.at(-1)?.files[0]).toMatchObject({ + path: "a.ts", + hunks: [ + { + lines: expect.arrayContaining([expect.objectContaining({ content: "const value = 1;" })]), + }, + ], }); - - await manager.subscribe( - { cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } }, - vi.fn(), - ); - - getOnWorkspaceSnapshot()?.( - createWorkspaceSnapshot({ - isDirty: true, - diffStat: { additions: 5, deletions: 2 }, - }), - ); - await vi.advanceTimersByTimeAsync(150); - - expect(getCheckoutDiff).toHaveBeenCalledTimes(1); - }); - - test("base diff subscriptions refresh for structural workspace changes", async () => { - const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] })); - const { manager, getOnWorkspaceSnapshot, workspaceGitService } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, - }); - - await manager.subscribe( - { cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } }, - vi.fn(), - ); - - expect(workspaceGitService.registerWorkspace).toHaveBeenCalledTimes(1); - getOnWorkspaceSnapshot()?.( - createWorkspaceSnapshot({ - currentBranch: "feature-2", - isDirty: true, - diffStat: { additions: 5, deletions: 2 }, - }), - ); - await vi.advanceTimersByTimeAsync(150); - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - expect(getCheckoutDiff.mock.calls[1]?.[2]).toBeUndefined(); - - getOnWorkspaceSnapshot()?.(createWorkspaceSnapshot({ aheadBehind: { ahead: 2, behind: 0 } })); - await vi.advanceTimersByTimeAsync(150); - expect(getCheckoutDiff).toHaveBeenCalledTimes(3); - }); - - test("falls back to cwd when the working tree watch returns no repo root", async () => { - const { manager, workspaceGitService } = createManager({ repoRoot: null }); - - await manager.subscribe( - { - cwd: "/tmp/plain", - compare: { mode: "uncommitted" }, - }, - () => {}, - ); - - expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith( - "/tmp/plain", - expect.objectContaining({ mode: "uncommitted", includeStructured: true }), - undefined, - ); + subscription.unsubscribe(); }); }); + +test("legacy subscriptions reselect the preferred base when a remote ref appears", async () => { + await withCheckout(async ({ cwd, manager, git }) => { + git("checkout", "-b", "feature"); + await writeFile(join(cwd, "b.ts"), "export const b = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "feature"); + const updates: CheckoutDiffSnapshotPayload[] = []; + const params = { cwd, compare: { mode: "base" as const, baseRef: "main" } }; + const subscription = await manager.subscribe(params, (snapshot) => updates.push(snapshot)); + try { + expect(subscription.initial.files.map((file) => file.path)).toEqual(["b.ts"]); + git("update-ref", "refs/remotes/origin/main", "HEAD"); + await manager.scheduleRefreshForCwd(cwd); + await expect.poll(() => updates.at(-1)?.files.length, { timeout: 5000 }).toBe(0); + expect(updates.at(-1)).toEqual(await manager.read(params)); + } finally { + await subscription.unsubscribe(); + } + }); +}); + +test("cold many-file reads batch Git patches while matching the legacy reader", async () => { + const { getCheckoutDiff } = await import("../utils/checkout-git.js"); + const { startGitCommandMetrics, stopGitCommandMetrics } = + await import("../utils/run-git-command.js"); + await withCheckout(async ({ cwd, manager, git }) => { + const count = 1000; + for (let index = 0; index < count; index++) + await writeFile(join(cwd, `file-${index}.ts`), "export const value = 0;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "many files"); + for (let index = 0; index < count; index++) + await writeFile(join(cwd, `file-${index}.ts`), "export const value = 1;\n"); + const legacyStart = performance.now(); + const legacy = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + const legacyMs = performance.now() - legacyStart; + startGitCommandMetrics(); + const streamedStart = performance.now(); + const streamed = await manager.read({ cwd, compare: { mode: "uncommitted" } }); + const streamedMs = performance.now() - streamedStart; + const metrics = stopGitCommandMetrics(); + const patches = metrics.commands.filter((command) => command.args.includes("--patch")); + const artifactDirectory = join(import.meta.dirname, "../../../../.artifacts/diff-subscription"); + await mkdir(artifactDirectory, { recursive: true }); + await writeFile( + join(artifactDirectory, "cold-many-file-metrics.json"), + JSON.stringify({ count, legacyMs, streamedMs, patchCommands: patches.length }, null, 2), + ); + expect(streamed.error).toBeNull(); + expect(streamed.files).toHaveLength(count); + expect(legacy.structured).toHaveLength(count); + expect(streamed.files.map((file) => [file.path, file.hunks])).toEqual( + legacy.structured?.map((file) => [file.path, file.hunks]), + ); + expect(patches.length).toBeLessThanOrEqual(Math.ceil(count / 64)); + }); +}, 60000); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 8293ce965f..921934e70e 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,400 +1,201 @@ -import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; +import { DiffSubscriptions, type CommitFileRead } from "./diff/index.js"; +import { + DiffOpcode, + materializeDiffFile, + type DiffFrame, + type DiffBegin, + type DiffBody, +} from "@getpaseo/protocol/binary-frames/index"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { + CHECKOUT_DIFF_MAX_STRUCTURED_BYTES, + resolveCheckoutDiffBase, +} from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { toCheckoutError } from "./checkout-git-utils.js"; -const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; - -type CheckoutDiffWorkspace = Pick< - WorkspaceGitService, - "getCheckoutDiff" | "getSnapshot" | "registerWorkspace" | "requestWorkingTreeWatch" ->; - export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; - export type CheckoutDiffSnapshotPayload = Omit< Extract["payload"], "subscriptionId" >; - export interface CheckoutDiffMetrics { checkoutDiffTargetCount: number; checkoutDiffSubscriptionCount: number; checkoutDiffWatcherCount: number; checkoutDiffFallbackRefreshTargetCount: number; } - -interface CheckoutDiffWatchTarget { - key: string; - cwd: string; - diffCwd: string; - compare: CheckoutDiffCompareInput; - listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>; - workingTreeWatchUnsubscribe: (() => void) | null; - workspaceGitUnsubscribe: (() => void) | null; - latestWorkspaceStructureFingerprint: string | null; - latestWorkspaceWorktreeFingerprint: string | null; - latestWorkspaceForgeFingerprint: string | null; - debounceTimer: NodeJS.Timeout | null; - pendingDebounceForce: boolean; - refreshPromise: Promise | null; - refreshQueued: boolean; - refreshQueuedForce: boolean; - latestPayload: CheckoutDiffSnapshotPayload | null; - latestFingerprint: string | null; - openPromise: Promise | null; -} - export interface CheckoutDiffSubscriptionRequest { cwd: string; compare: CheckoutDiffCompareInput; signal?: AbortSignal; } - export interface CheckoutDiffSubscription { initial: CheckoutDiffSnapshotPayload; - unsubscribe: () => void; + unsubscribe(): void; } +// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06. +// Old clients receive the same JSON snapshot while the shared engine owns reads and observation. export class CheckoutDiffManager { - private readonly workspaceGitService: CheckoutDiffWorkspace; - private readonly workspaceRuntimeStore: WorkspaceRuntimeStore; - private readonly targets = new Map(); - + readonly streams: DiffSubscriptions; + private readonly paseoHome: string; constructor(options: { logger: pino.Logger; paseoHome: string; - workspaceGitService: CheckoutDiffWorkspace; + workspaceGitService: Pick; workspaceRuntimeStore: WorkspaceRuntimeStore; }) { - this.workspaceGitService = options.workspaceGitService; - this.workspaceRuntimeStore = options.workspaceRuntimeStore; + this.streams = new DiffSubscriptions(options.workspaceGitService); + this.paseoHome = options.paseoHome; + } + readCommitFile(input: CommitFileRead) { + return this.streams.readCommitFile(input); } - async read( params: Omit, ): Promise { - return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare)); + const subscription = await this.subscribe(params, () => {}); + await subscription.unsubscribe(); + return subscription.initial; } - async subscribe( params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): Promise { - const cwd = params.cwd; - const compare = this.normalizeCompare(params.compare); - const target = this.ensureTarget(cwd, compare); - target.listeners.add(listener); - target.openPromise ??= this.openTarget(target); - - let isSubscribed = true; - const unsubscribe = () => { - if (!isSubscribed) { + const cwd = expandTilde(params.cwd); + let resolveInitial!: (snapshot: CheckoutDiffSnapshotPayload) => void; + const initial = new Promise((resolve) => { + resolveInitial = resolve; + }); + let delivered = false; + let pending: DiffBegin | null = null; + let files = new Map(); + let bodies = new Map(); + const receive = async (frame: DiffFrame) => { + if (params.signal?.aborted) return; + if (frame.opcode === DiffOpcode.Begin) { + pending = frame; + bodies = new Map(); return; } - isSubscribed = false; - params.signal?.removeEventListener("abort", unsubscribe); - this.removeListener(target, listener); - }; - params.signal?.addEventListener("abort", unsubscribe, { once: true }); - if (params.signal?.aborted) { - unsubscribe(); - } - - try { - await target.openPromise; - const initial = - target.latestPayload ?? - (await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { - diffCwd: target.diffCwd, - })); - target.latestPayload = initial; - target.latestFingerprint = JSON.stringify(initial); - return { initial, unsubscribe }; - } catch (error) { - unsubscribe(); - throw error; - } - } - - scheduleRefreshForCwd(cwd: string): void { - const resolvedCwd = expandTilde(cwd); - for (const target of this.targets.values()) { - if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) { - continue; + if (!pending || frame.revision !== pending.revision) return; + if (frame.opcode === DiffOpcode.Bodies) { + for (const body of frame.files) bodies.set(body.path, body); + return; } - this.scheduleTargetRefresh(target); + let payload: CheckoutDiffSnapshotPayload; + if (frame.error) { + const message = frame.error.message; + payload = { + cwd, + files: [], + error: toCheckoutError(new Error(message)), + diffTooLarge: frame.diffTooLarge, + }; + files.clear(); + } else { + const next = new Map(); + const omitted = new Set(frame.omitted); + for (const entry of pending.entries) { + const previous = pending.baseRevision ? files.get(entry.path) : undefined; + const body = bodies.get(entry.path) ?? null; + const file = materializeDiffFile(entry, body, omitted.has(entry.path)); + if (!entry.body && previous && entry.content === "text" && !omitted.has(entry.path)) + file.hunks = previous.hunks; + next.set(entry.path, file); + } + files = next; + // Stream omissions remain file placeholders; the JSON budget rejects a legacy snapshot. + payload = legacySnapshot(cwd, [...next.values()]); + } + pending = null; + if (delivered) listener(payload); + else { + delivered = true; + resolveInitial(payload); + } + }; + try { + const baseRef = await resolveCheckoutDiffBase(cwd, params.compare, { + paseoHome: this.paseoHome, + }); + if (!baseRef) return { initial: { cwd, files: [], error: null }, unsubscribe() {} }; + const subscription = await this.streams.subscribe( + { + cwd, + base: { kind: "rev", rev: baseRef }, + checkoutBase: + params.compare.mode === "uncommitted" + ? undefined + : { compare: params.compare, paseoHome: this.paseoHome }, + head: + params.compare.mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" }, + mergeBase: params.compare.mode !== "uncommitted", + ignoreWhitespace: params.compare.ignoreWhitespace, + }, + receive, + ); + const unsubscribe = async () => { + if (!delivered) { + delivered = true; + resolveInitial({ + cwd, + files: [], + error: toCheckoutError(new Error("Diff subscription cancelled")), + }); + } + params.signal?.removeEventListener("abort", unsubscribe); + await subscription.release(); + }; + params.signal?.addEventListener("abort", unsubscribe, { once: true }); + if (params.signal?.aborted) { + await subscription.release(); + throw new Error("Diff subscription cancelled"); + } + await subscription.start(); + return { initial: await initial, unsubscribe }; + } catch (error) { + return { initial: { cwd, files: [], error: toCheckoutError(error) }, unsubscribe() {} }; } } - + scheduleRefreshForCwd(cwd: string): Promise { + return this.streams.invalidateForCwd(expandTilde(cwd)); + } getMetrics(): CheckoutDiffMetrics { - let checkoutDiffSubscriptionCount = 0; - - for (const target of this.targets.values()) { - checkoutDiffSubscriptionCount += target.listeners.size; - } - + const metrics = this.streams.getMetrics(); return { - checkoutDiffTargetCount: this.targets.size, - checkoutDiffSubscriptionCount, - checkoutDiffWatcherCount: 0, + checkoutDiffTargetCount: metrics.targets, + checkoutDiffSubscriptionCount: metrics.subscriptions, + checkoutDiffWatcherCount: metrics.metadataObservers, checkoutDiffFallbackRefreshTargetCount: 0, }; } - - dispose(): void { - for (const target of this.targets.values()) { - this.closeTarget(target); - } - this.targets.clear(); - } - - private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput { - const ignoreWhitespace = compare.ignoreWhitespace === true; - if (compare.mode === "uncommitted") { - return { mode: "uncommitted", ignoreWhitespace }; - } - const trimmedBaseRef = compare.baseRef?.trim(); - return trimmedBaseRef - ? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace } - : { mode: compare.mode, ignoreWhitespace }; - } - - private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string { - return JSON.stringify([ - cwd, - compare.mode, - compare.mode !== "uncommitted" ? (compare.baseRef ?? "") : "", - compare.ignoreWhitespace === true, - ]); - } - - private closeTarget(target: CheckoutDiffWatchTarget): void { - if (target.debounceTimer) { - clearTimeout(target.debounceTimer); - target.debounceTimer = null; - } - target.workingTreeWatchUnsubscribe?.(); - target.workingTreeWatchUnsubscribe = null; - target.workspaceGitUnsubscribe?.(); - target.workspaceGitUnsubscribe = null; - target.listeners.clear(); - } - - private rememberWorkspaceSnapshot( - target: CheckoutDiffWatchTarget, - snapshot: WorkspaceGitRuntimeSnapshot, - ): void { - const structureFingerprint = JSON.stringify({ - isGit: snapshot.git.isGit, - repoRoot: snapshot.git.repoRoot, - mainRepoRoot: snapshot.git.mainRepoRoot, - currentBranch: snapshot.git.currentBranch, - remoteUrl: snapshot.git.remoteUrl, - isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree, - baseRef: snapshot.git.baseRef, - baseRefExact: snapshot.git.baseRefExact, - aheadBehind: snapshot.git.aheadBehind, - aheadOfOrigin: snapshot.git.aheadOfOrigin, - behindOfOrigin: snapshot.git.behindOfOrigin, - hasRemote: snapshot.git.hasRemote, - }); - const worktreeFingerprint = JSON.stringify({ - isDirty: snapshot.git.isDirty, - diffStat: snapshot.git.diffStat, - }); - const forgeFingerprint = JSON.stringify(snapshot.forge); - const previousStructureFingerprint = target.latestWorkspaceStructureFingerprint; - const previousWorktreeFingerprint = target.latestWorkspaceWorktreeFingerprint; - const previousForgeFingerprint = target.latestWorkspaceForgeFingerprint; - target.latestWorkspaceStructureFingerprint = structureFingerprint; - target.latestWorkspaceWorktreeFingerprint = worktreeFingerprint; - target.latestWorkspaceForgeFingerprint = forgeFingerprint; - - if (previousStructureFingerprint === null) { - return; - } - const structureChanged = structureFingerprint !== previousStructureFingerprint; - const worktreeChanged = worktreeFingerprint !== previousWorktreeFingerprint; - const forgeChanged = forgeFingerprint !== previousForgeFingerprint; - if (structureChanged || (!worktreeChanged && !forgeChanged)) { - this.scheduleTargetRefresh(target, false); - } - } - - private removeListener( - target: CheckoutDiffWatchTarget, - listener: (snapshot: CheckoutDiffSnapshotPayload) => void, - ): void { - target.listeners.delete(listener); - if (target.listeners.size > 0) { - return; - } - this.closeTarget(target); - if (this.targets.get(target.key) === target) { - this.targets.delete(target.key); - } - } - - private scheduleTargetRefresh(target: CheckoutDiffWatchTarget, force = true): void { - target.pendingDebounceForce ||= force; - if (target.debounceTimer) { - clearTimeout(target.debounceTimer); - } - target.debounceTimer = setTimeout(() => { - target.debounceTimer = null; - const pendingForce = target.pendingDebounceForce; - target.pendingDebounceForce = false; - void this.refreshTarget(target, pendingForce); - }, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS); - } - - private async computeCheckoutDiffSnapshot( - cwd: string, - compare: CheckoutDiffCompareInput, - options?: { diffCwd?: string; force?: boolean; reason?: string }, - ): Promise { - const diffCwd = options?.diffCwd ?? cwd; - try { - const diffResult = await this.workspaceGitService.getCheckoutDiff( - diffCwd, - { - mode: compare.mode, - baseRef: compare.baseRef, - ignoreWhitespace: compare.ignoreWhitespace, - includeStructured: true, - }, - options?.force - ? { force: true, reason: options.reason ?? "checkout-diff-refresh" } - : undefined, - ); - if (diffResult.diffTooLarge) { - return { - cwd, - files: [], - diffTooLarge: true, - error: toCheckoutError(new Error("Diff too large to display")), - }; - } - const files = [...(diffResult.structured ?? [])]; - files.sort((a, b) => { - if (a.path === b.path) return 0; - return a.path < b.path ? -1 : 1; - }); - return { - cwd, - files, - error: null, - }; - } catch (error) { - return { - cwd, - files: [], - error: toCheckoutError(error), - }; - } - } - - private async refreshTarget(target: CheckoutDiffWatchTarget, force: boolean): Promise { - if (target.refreshPromise) { - target.refreshQueued = true; - target.refreshQueuedForce ||= force; - return; - } - - target.refreshPromise = (async () => { - let currentForce = force; - do { - target.refreshQueued = false; - target.refreshQueuedForce = false; - const snapshot = await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { - diffCwd: target.diffCwd, - force: currentForce, - ...(currentForce ? { reason: "working-tree-watch" } : {}), - }); - target.latestPayload = snapshot; - const fingerprint = JSON.stringify(snapshot); - if (fingerprint !== target.latestFingerprint) { - target.latestFingerprint = fingerprint; - for (const listener of target.listeners) { - listener(snapshot); - } - } - currentForce = target.refreshQueuedForce; - } while (target.refreshQueued); - })(); - - try { - await target.refreshPromise; - } finally { - target.refreshPromise = null; - } - } - - private ensureTarget(cwd: string, compare: CheckoutDiffCompareInput): CheckoutDiffWatchTarget { - const targetKey = this.buildTargetKey(cwd, compare); - const existing = this.targets.get(targetKey); - if (existing) { - return existing; - } - - const target: CheckoutDiffWatchTarget = { - key: targetKey, - cwd, - diffCwd: cwd, - compare, - listeners: new Set(), - workingTreeWatchUnsubscribe: null, - workspaceGitUnsubscribe: null, - latestWorkspaceStructureFingerprint: null, - latestWorkspaceWorktreeFingerprint: null, - latestWorkspaceForgeFingerprint: null, - debounceTimer: null, - pendingDebounceForce: false, - refreshPromise: null, - refreshQueued: false, - refreshQueuedForce: false, - latestPayload: null, - latestFingerprint: null, - openPromise: null, - }; - this.targets.set(targetKey, target); - return target; - } - - private async openTarget(target: CheckoutDiffWatchTarget): Promise { - if (target.compare.mode !== "uncommitted") { - const snapshot = - this.workspaceRuntimeStore.get(target.cwd) ?? - (await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false })); - target.diffCwd = snapshot.git.repoRoot ?? target.cwd; - if (this.targets.get(target.key) !== target || target.listeners.size === 0) { - return; - } - this.rememberWorkspaceSnapshot(target, snapshot); - const workspaceSubscription = this.workspaceGitService.registerWorkspace( - { cwd: target.cwd }, - (nextSnapshot) => this.rememberWorkspaceSnapshot(target, nextSnapshot), - ); - if (this.targets.get(target.key) !== target || target.listeners.size === 0) { - workspaceSubscription.unsubscribe(); - return; - } - target.workspaceGitUnsubscribe = workspaceSubscription.unsubscribe; - if (target.compare.mode === "base") return; - } - - const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( - target.cwd, - () => this.scheduleTargetRefresh(target), - ); - target.diffCwd = repoRoot ?? target.cwd; - if (this.targets.get(target.key) !== target || target.listeners.size === 0) { - unsubscribe(); - return; - } - target.workingTreeWatchUnsubscribe = unsubscribe; + async dispose(): Promise { + await this.streams.close(); } } + +function legacySnapshot(cwd: string, files: ParsedDiffFile[]): CheckoutDiffSnapshotPayload { + let bytes = 2; + for (let index = 0; index < files.length; index++) { + bytes += Buffer.byteLength(JSON.stringify(files[index])) + Number(index > 0); + if (bytes > CHECKOUT_DIFF_MAX_STRUCTURED_BYTES) { + return legacyTooLarge(cwd); + } + } + return { cwd, files, error: null }; +} + +function legacyTooLarge(cwd: string): CheckoutDiffSnapshotPayload { + return { + cwd, + files: [], + diffTooLarge: true, + error: toCheckoutError(new Error("Diff too large to display")), + }; +} diff --git a/packages/server/src/server/checkout/status-projection.ts b/packages/server/src/server/checkout/status-projection.ts index 90c900435c..93242cb941 100644 --- a/packages/server/src/server/checkout/status-projection.ts +++ b/packages/server/src/server/checkout/status-projection.ts @@ -62,6 +62,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef, baseRefExact: snapshot.git.baseRefExact, + comparisonBaseRef: snapshot.git.comparisonBaseRef, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, @@ -83,6 +84,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef ?? null, baseRefExact: snapshot.git.baseRefExact, + comparisonBaseRef: snapshot.git.comparisonBaseRef, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index 56e7cdefb8..b297d07b5a 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -293,3 +293,263 @@ test("large source diffs stay renderable for reads, subscriptions, and commit fi await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 60_000); + +test("streams whole-file batches and answers a same-socket ping before the revision ends", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "diff-stream-wire-")); + let probe: Awaited> | null = null; + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "README"), "base\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + for (let file = 0; file < 252; file++) { + const content = + Array.from( + { length: 80 }, + (_, line) => + `export const fn${line} = (param: string) => ({ key: param, value: ${file} });`, + ).join("\n") + "\n"; + writeFileSync(join(cwd, `${file}.ts`), content); + } + expect(ctx.client.getLastServerInfoMessage()).toMatchObject({ + features: { diffSubscription: true }, + }); + const legacy = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + const legacyJsonBytes = Buffer.byteLength(JSON.stringify(legacy.files)); + expect(legacyJsonBytes).toBeGreaterThanOrEqual(10 * 1024 * 1024); + probe = await openDiffWireProbe(ctx.daemon.port, cwd); + const { encodedBytes, bodyFrames, pongDuringRevision, entryCount } = await probe.done; + expect(entryCount).toBe(252); + expect(bodyFrames).toBeGreaterThan(1); + expect(pongDuringRevision).toBe(true); + const artifactDirectory = join( + import.meta.dirname, + "../../../../../.artifacts/diff-subscription", + ); + mkdirSync(artifactDirectory, { recursive: true }); + writeFileSync( + join(artifactDirectory, "wire-metrics.json"), + JSON.stringify({ legacyJsonBytes, encodedBytes, bodyFrames, pongDuringRevision }, null, 2), + ); + } finally { + probe?.socket.terminate(); + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 60_000); + +interface DiffWireResult { + encodedBytes: number; + bodyFrames: number; + pongDuringRevision: boolean; + entryCount: number; +} +async function openDiffWireProbe(port: number, cwd: string) { + const { WebSocket } = await import("ws"); + const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index"); + const activeSocket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + await new Promise((resolve, reject) => { + activeSocket.once("open", resolve); + activeSocket.once("error", reject); + }); + let encodedBytes = 0; + let endReceived = false; + let pingSent = false; + let pongDuringRevision = false; + let bodyFrames = 0; + let entryCount = 0; + const done = new Promise((resolve, reject) => { + activeSocket.on("message", (data, binary) => { + try { + if (binary) { + if (!Buffer.isBuffer(data)) throw new Error("Expected buffer"); + encodedBytes += data.byteLength; + const frame = decodeDiffFrame(new Uint8Array(data)); + if (frame.opcode === DiffOpcode.Begin) entryCount = frame.entries.length; + if (frame.opcode === DiffOpcode.Bodies) { + bodyFrames++; + if (!pingSent) { + pingSent = true; + activeSocket.send( + JSON.stringify({ + type: "session", + message: { type: "ping", requestId: "mid-revision", clientSentAt: 1 }, + }), + ); + } + } + if (frame.opcode === DiffOpcode.End) { + expect(frame.error).toBeNull(); + endReceived = true; + resolve({ encodedBytes, bodyFrames, pongDuringRevision, entryCount }); + } + } else { + const envelope = JSON.parse(data.toString()); + if ( + envelope.message?.type === "pong" && + envelope.message.payload.requestId === "mid-revision" + ) + pongDuringRevision = !endReceived; + if (envelope.message?.payload?.status === "server_info") + activeSocket.send( + JSON.stringify({ + type: "session", + message: { + type: "diff.subscribe.request", + requestId: "stream-diff", + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }, + }), + ); + if (envelope.message?.type === "diff.subscribe.response") + expect(envelope.message.payload.error).toBeNull(); + } + } catch (error) { + reject(error); + } + }); + activeSocket.once("error", reject); + }); + activeSocket.send( + JSON.stringify({ + type: "hello", + clientId: "diff-wire-test", + clientType: "browser", + protocolVersion: 1, + capabilities: { owned_subscriptions: true }, + }), + ); + return { socket: activeSocket, done }; +} + +test("SDK diff journey publishes a tag tree, commits a one-file refresh and shares two sockets", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "sdk-stream-diff-")); + const { DaemonClient } = await import("../test-utils/daemon-client.js"); + const { startGitCommandMetrics, stopGitCommandMetrics } = + await import("../../utils/run-git-command.js"); + const second = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "a.ts"), "export const a = 1;\n"); + writeFileSync(join(cwd, "b.ts"), "export const b = 1;\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + git(cwd, "tag", "v1"); + writeFileSync(join(cwd, "a.ts"), "export const a = 2;\n"); + writeFileSync(join(cwd, "b.ts"), "export const b = 2;\n"); + const compare = { + cwd, + base: { kind: "rev", rev: "refs/tags/v1" }, + head: { kind: "worktree" }, + mergeBase: true, + } as const; + const states: import("@getpaseo/client").DiffState[] = []; + const observation = ctx.client.observeDiff(compare); + observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} }); + await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready"); + expect(states[0]).toMatchObject({ + phase: "loading", + entries: [{ path: "a.ts" }, { path: "b.ts" }], + }); + const initial = states.at(-1); + if (initial?.phase !== "ready") throw new Error("Expected ready"); + const initialStateCount = states.length; + await second.connect(); + startGitCommandMetrics(); + const duplicateStates: import("@getpaseo/client").DiffState[] = []; + const duplicate = second.observeDiff(compare); + duplicate.subscribe({ snapshot: (state) => duplicateStates.push(state), update: () => {} }); + await expect.poll(() => duplicateStates.at(-1)?.phase).toBe("ready"); + const metrics = stopGitCommandMetrics(); + expect(duplicateStates.at(-1)).toMatchObject({ + phase: "ready", + files: [{ path: "a.ts" }, { path: "b.ts" }], + }); + expect(metrics.commands.filter((command) => command.args.includes("--raw"))).toHaveLength(0); + startGitCommandMetrics(); + writeFileSync(join(cwd, "a.ts"), "export const a = 3;\n"); + await expect + .poll(() => states.at(-1)?.revision, { timeout: 15000 }) + .toBeGreaterThan(initial.revision); + await expect.poll(() => duplicateStates.at(-1)?.revision).toBeGreaterThan(initial.revision); + const refreshMetrics = stopGitCommandMetrics(); + expect( + refreshMetrics.commands.filter((command) => command.args.includes("--raw")), + ).toHaveLength(1); + const refreshed = states.at(-1); + if (refreshed?.phase !== "ready") throw new Error("Expected ready"); + expect(refreshed.files[0]).not.toBe(initial.files[0]); + expect(refreshed.files[1]).toBe(initial.files[1]); + expect(states.slice(initialStateCount).map((state) => state.phase)).not.toContain("loading"); + expect(states.map((state) => state.phase)).not.toContain("error"); + await duplicate.release(); + await observation.release(); + } finally { + await second.close(); + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 45000); + +test("commit-file RPC keeps the existing read ceiling for patches larger than one MiB", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "commit-file-budget-")); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "large.txt"), "base\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + writeFileSync(join(cwd, "large.txt"), ("x".repeat(100) + "\n").repeat(11000)); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "large change"); + const sha = git(cwd, "rev-parse", "HEAD").trim(); + const { getCommitFileDiff } = await import("../../utils/checkout-git.js"); + const expected = await getCommitFileDiff({ cwd, sha, path: "large.txt" }); + const actual = await ctx.client.getCommitFileDiff(cwd, sha, "large.txt"); + expect(expected?.hunks.length).toBeGreaterThan(0); + expect(actual.file?.hunks).toEqual(expected?.hunks); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + +test("commit-file RPC selects renamed content when the source path becomes a directory", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "commit-rename-directory-")); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "a.txt"), "original line\n".repeat(16)); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + git(cwd, "mv", "a.txt", "z.txt"); + writeFileSync(join(cwd, "z.txt"), "original line\n".repeat(16) + "renamed line\n"); + mkdirSync(join(cwd, "a.txt")); + writeFileSync(join(cwd, "a.txt", "child.txt"), "replacement line\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename"); + const sha = git(cwd, "rev-parse", "HEAD").trim(); + const { getCommitFileDiff } = await import("../../utils/checkout-git.js"); + const expected = await getCommitFileDiff({ cwd, sha, path: "z.txt" }); + const actual = await ctx.client.getCommitFileDiff(cwd, sha, "z.txt"); + expect(expected?.hunks.flatMap((hunk) => hunk.lines.map((line) => line.content))).toContain( + "renamed line", + ); + expect(actual.file?.hunks).toEqual(expected?.hunks); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts index 3af53a9133..40d410f6c1 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts @@ -142,7 +142,8 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy commitFile(cwd, "large-b.js", "const value = 0;\n"); // Short lines stay eligible for highlighting; a giant single line is skipped // before token expansion and cannot exercise the structured-payload limit. - const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(4_000); + // Stay below the full-source highlighting budget as well. + const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(3_500); writeFileSync(path.join(cwd, "large-a.js"), denseExpression); writeFileSync(path.join(cwd, "large-b.js"), denseExpression); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index da52c4dac4..068e0628d0 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -690,6 +690,7 @@ describe("commit file diffs", () => { expect(result).toEqual({ file: { path: filePath, + status: "ok", isNew: false, isDeleted: false, additions: 1, diff --git a/packages/server/src/server/diff/batches.ts b/packages/server/src/server/diff/batches.ts new file mode 100644 index 0000000000..25628eed57 --- /dev/null +++ b/packages/server/src/server/diff/batches.ts @@ -0,0 +1,30 @@ +import { encodeDiffFrame, DiffOpcode, type DiffBody } from "@getpaseo/protocol/binary-frames/index"; + +export async function* packBodies( + files: AsyncIterable | Iterable, + revision: number, +): AsyncGenerator { + let batch: DiffBody[] = []; + let size = 0; + for await (const file of files) { + const bytes = encodeDiffFrame({ + opcode: DiffOpcode.Bodies, + slot: 0, + revision, + files: [file], + }).length; + if (batch.length && size + bytes > 256 * 1024) { + yield batch; + batch = []; + size = 0; + } + batch.push(file); + size += bytes; + if (size >= 256 * 1024) { + yield batch; + batch = []; + size = 0; + } + } + if (batch.length) yield batch; +} diff --git a/packages/server/src/server/diff/dependencies.ts b/packages/server/src/server/diff/dependencies.ts new file mode 100644 index 0000000000..81b9cd194b --- /dev/null +++ b/packages/server/src/server/diff/dependencies.ts @@ -0,0 +1,92 @@ +import { + affectsGitDiffInterpretation, + classifyGitMetadataPath, + type GitMetadataScope, +} from "../git-metadata-event-rules.js"; +import { resolve } from "node:path"; +import { runGitCommand } from "../../utils/run-git-command.js"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; +import { createFileObserver, type FileObserverSubscription } from "../file-observer/index.js"; + +export interface DiffDependencies { + cwd: string; + refs: string[]; + index: boolean; + worktree: boolean; +} +// Own metadata observations here; comparison managers declare inputs rather than watching paths. +export class DiffDependencyObserver { + private observer = createFileObserver(); + constructor(private workspaceGit: Pick) {} + async declareDependencies( + input: DiffDependencies, + changed: (kind: "endpoints" | "interpretation") => void, + ): Promise<() => Promise> { + const hasMutableEndpoints = input.refs.length > 0 || input.index || input.worktree; + let polling: ReturnType | null = null; + const subscriptions: FileObserverSubscription[] = []; + let workingTree: Awaited> | null = + null; + try { + const paths = await runGitCommand(["rev-parse", "--absolute-git-dir", "--git-common-dir"], { + cwd: input.cwd, + }); + const [gitDir, commonDir] = paths.stdout.split(/\r?\n/); + if (!gitDir || !commonDir) throw new Error("Git returned incomplete metadata roots"); + const roots = new Map(); + for (const [directory, scope] of [ + [gitDir, "worktree"], + [commonDir, "common"], + ] as const) { + const root = resolve(input.cwd, directory); + const scopes = roots.get(root) ?? []; + scopes.push(scope); + roots.set(root, scopes); + } + for (const [root, scopes] of roots) { + const subscription = await this.observer.subscribe( + root, + (error, events) => { + if (error) { + changed("endpoints"); + polling ??= setInterval(() => changed("endpoints"), 5000); + return; + } + let interpretation = false; + const endpoints = events.some((event) => { + const path = event.path.slice(root.length + 1).replaceAll("\\", "/"); + if (scopes.some((scope) => affectsGitDiffInterpretation(scope, path))) + interpretation = true; + if (!hasMutableEndpoints) return false; + if (path === "index") return input.index; + return scopes.some((scope) => classifyGitMetadataPath(scope, path).kind !== "ignore"); + }); + if (endpoints) changed("endpoints"); + else if (interpretation) changed("interpretation"); + }, + { ignore: [resolve(root, "objects"), resolve(root, "logs"), resolve(root, "hooks")] }, + ); + subscriptions.push(subscription); + } + workingTree = await this.workspaceGit.requestWorkingTreeWatch(input.cwd, () => + changed(input.worktree ? "endpoints" : "interpretation"), + ); + return async () => { + if (polling) clearInterval(polling); + await workingTree?.unsubscribe(); + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + }; + } catch (error) { + if (polling) clearInterval(polling); + await workingTree?.unsubscribe(); + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + throw error; + } + } + getObservationCount(): number { + return this.observer.getDiagnostics().activeObservationCount; + } + async close(): Promise { + await this.observer.close(); + } +} diff --git a/packages/server/src/server/diff/git.test.ts b/packages/server/src/server/diff/git.test.ts new file mode 100644 index 0000000000..5c77f80e45 --- /dev/null +++ b/packages/server/src/server/diff/git.test.ts @@ -0,0 +1,719 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, writeFile, rm, readFile, lstat } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { listComparison, type Comparison } from "./git.js"; + +test("working tree comparisons preserve staged deletions whose file remains on disk", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-comparison-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "x.ts"), "const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + git("rm", "--cached", "x.ts"); + await writeFile(join(cwd, "x.ts"), "const value = 2;\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + expect(comparison.files.map((file) => file.entry)).toEqual([ + { + path: "x.ts", + oldPath: null, + change: "modified", + content: "text", + additions: 1, + deletions: 1, + body: true, + }, + ]); + const body = await readBody(comparison); + expect(body.body.hunks[0].lines).toEqual([ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "const value = 1;" }, + { type: "add", content: "const value = 2;" }, + ]); + expect(comparison.metrics.highlightCalls).toBe(0); + await comparison.close(); + expect(git("diff", "--cached", "--name-status")).toBe("D\tx.ts"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("supports empty trees, unborn HEAD, staged and unstaged comparisons", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-endpoints-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "x.ts"), "const value = 1;\n"); + const unborn = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + expect(unborn.files.map((file) => file.entry.path)).toEqual(["x.ts"]); + await unborn.close(); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const empty = await listComparison({ + cwd, + base: { kind: "empty" }, + head: { kind: "rev", rev: "HEAD" }, + }); + expect(empty.files[0].entry.change).toBe("added"); + await empty.close(); + await writeFile(join(cwd, "x.ts"), "const value = 2;\n"); + git("add", "."); + const indexPath = join(cwd, ".git", "index"); + const originalIndex = await readFile(indexPath); + const originalMtime = (await lstat(indexPath, { bigint: true })).mtimeNs; + const staged = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "index" }, + highlight: false, + }); + expect(await readFile(indexPath)).toEqual(originalIndex); + expect((await lstat(indexPath, { bigint: true })).mtimeNs).toBe(originalMtime); + const stagedSha = staged.headSha; + expect(staged.files[0].entry).toMatchObject({ additions: 1, deletions: 1 }); + await staged.close(); + await writeFile(join(cwd, "x.ts"), "const value = 3;\n"); + const unstaged = await listComparison({ + cwd, + base: { kind: "index" }, + head: { kind: "worktree" }, + highlight: false, + }); + expect(unstaged.baseSha).toBe(stagedSha); + const record = await readBody(unstaged); + expect(record.body.hunks[0].lines).toContainEqual({ + type: "remove", + content: "const value = 2;", + }); + await unstaged.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("lists oversized untracked files without treating their truncated contents as additions", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-large-file-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "huge.ts"), Buffer.alloc(2 * 1024 * 1024, 120)); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + expect(comparison.files[0].entry).toMatchObject({ + path: "huge.ts", + additions: 0, + deletions: 0, + content: "text", + }); + expect(await comparison.read(comparison.files[0])).toEqual({ kind: "too_large" }); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("preserves highlighting context outside the emitted hunks", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-highlight-context-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + const lines = [ + "/*", + ...Array.from({ length: 30 }, (_, index) => `comment ${index}`), + "*/", + "export const value = 1;", + ]; + await writeFile(join(cwd, "x.ts"), lines.join("\n") + "\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + lines[15] = "new comment"; + await writeFile(join(cwd, "x.ts"), lines.join("\n") + "\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + const record = await readBody(comparison); + expect(record.body.hunks[0].lines.find((line) => line.type === "add")).toMatchObject({ + content: "new comment", + runs: [{ style: "comment", length: 11 }], + }); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("reads patches independently of repository color and prefix presentation settings", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-presentation-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "x.ts"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + git("config", "color.ui", "always"); + git("config", "diff.noprefix", "true"); + await writeFile(join(cwd, "x.ts"), "after\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + const record = await readBody(comparison); + expect(record.body.hunks).toHaveLength(1); + expect(record.body.hunks[0].lines).toContainEqual({ type: "add", content: "after" }); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("merge-base index comparisons resolve against HEAD while reporting both endpoint SHAs", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-index-merge-base-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const common = git("rev-parse", "HEAD"); + git("checkout", "-b", "side"); + await writeFile(join(cwd, "side.txt"), "side\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "side"); + const side = git("rev-parse", "HEAD"); + git("checkout", "main"); + await writeFile(join(cwd, "main.txt"), "main\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "main"); + await writeFile(join(cwd, "staged.txt"), "staged\n"); + git("add", "staged.txt"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "side" }, + head: { kind: "index" }, + mergeBase: true, + }); + expect(comparison.baseSha).toBe(side); + expect(comparison.mergeBaseSha).toBe(common); + expect(comparison.files.map((file) => file.entry.path)).toEqual(["main.txt", "staged.txt"]); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +async function readBody(comparison: Comparison) { + const record = await comparison.read(comparison.files[0]); + if (record.kind !== "ready") throw new Error("Expected a readable file body"); + return record; +} + +test.each(["worktree", "index", "rev"] as const)( + "%s whitespace filtering omits spacing-only edits and retains meaningful edits", + async (headKind) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-whitespace-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "a.ts"), "const x = 1;\n"); + await writeFile(join(cwd, "b.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD").trim(); + await writeFile(join(cwd, "a.ts"), "const x = 1;\n"); + await writeFile(join(cwd, "b.txt"), "after\n"); + if (headKind !== "worktree") git("add", "."); + if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind }; + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: base }, + head, + ignoreWhitespace: true, + highlight: false, + }); + expect(comparison.files.map((file) => file.entry.path)).toEqual(["b.txt"]); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test.skipIf(process.platform === "win32")( + "comparison roots preserve trailing folder spaces", + async () => { + const root = await mkdtemp(join(tmpdir(), "diff-space-")); + const cwd = join(root, "repo "); + const { mkdir } = await import("node:fs/promises"); + await mkdir(cwd); + try { + execFileSync("git", ["init", "-b", "main"], { cwd }); + const { worktreeRoot } = await import("./git.js"); + expect(await worktreeRoot(cwd)).toBe(cwd); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); + +test.each(["worktree", "index", "rev"] as const)( + "%s rename reads select the destination when the old path becomes a directory", + async (headKind) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-rename-directory-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const { mkdir } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "original line\n".repeat(16)); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + git("mv", "a.txt", "z.txt"); + await writeFile(join(cwd, "z.txt"), "original line\n".repeat(16) + "renamed line\n"); + await mkdir(join(cwd, "a.txt")); + await writeFile(join(cwd, "a.txt", "child.txt"), "replacement line\n"); + git("add", "."); + if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "rename"); + const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind }; + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: base }, + head, + highlight: false, + }); + try { + const file = comparison.files.find((candidate) => candidate.entry.path === "z.txt"); + if (!file) throw new Error("Missing rename"); + expect(file.entry.oldPath).toBe("a.txt"); + const record = await comparison.read(file); + if (record.kind !== "ready") throw new Error("Expected readable rename"); + const lines = record.body.hunks.flatMap((hunk) => hunk.lines.map((line) => line.content)); + expect(lines).toContain("renamed line"); + expect(lines).not.toContain("replacement line"); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test.skipIf(process.platform === "win32")( + "a dangling untracked symlink exposes its target text alongside tracked changes", + async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-dangling-link-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { symlink } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.txt"), "after\n"); + await symlink("not-created-yet", join(cwd, "link")); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + try { + expect(comparison.files.map((file) => file.entry.path)).toEqual(["a.txt", "link"]); + const record = await comparison.read(comparison.files[1]); + if (record.kind !== "ready") throw new Error("Expected link body"); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({ + type: "add", + content: "not-created-yet", + }); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test("untracked nested repositories do not hide tracked edits", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-nested-repository-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { mkdir } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.txt"), "after\n"); + await mkdir(join(cwd, "nested")); + git("-C", "nested", "init"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + try { + expect(comparison.files.map((file) => file.entry.path)).toEqual(["a.txt"]); + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({ + type: "add", + content: "after", + }); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.each(["worktree", "index", "rev"] as const)( + "%s preserves hunks for paths containing a patch prefix delimiter", + async (headKind) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-path-delimiter-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const { mkdir } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await mkdir(join(cwd, "dir b")); + await writeFile(join(cwd, "dir b", "file.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "dir b", "file.txt"), "after\n"); + if (headKind !== "worktree") git("add", "."); + if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind }; + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: base }, + head, + highlight: false, + }); + try { + expect(comparison.files[0].entry.path).toBe("dir b/file.txt"); + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({ + type: "add", + content: "after", + }); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test("highlighted BOM source content survives the production frame codec", async () => { + const { encodeDiffFrame, decodeDiffFrame } = + await import("@getpaseo/protocol/binary-frames/index"); + const cwd = await mkdtemp(join(tmpdir(), "diff-bom-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + try { + git("init", "-b", "main"); + await writeFile(join(cwd, "bom.ts"), "\uFEFFexport const value = 1;\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "empty" }, + head: { kind: "worktree" }, + }); + try { + const record = await readBody(comparison); + const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + expect( + record.body.hunks + .flatMap((hunk) => hunk.lines) + .some((line) => line.content.startsWith("\uFEFF")), + ).toBe(true); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.skipIf(process.platform === "win32")( + "symlink replacement retains both patch sections", + async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-type-change-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { symlink, unlink } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await symlink("old-target", join(cwd, "file.txt")); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await unlink(join(cwd, "file.txt")); + await writeFile(join(cwd, "file.txt"), "replacement contents\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + try { + expect(comparison.files[0].entry).toMatchObject({ additions: 1, deletions: 1 }); + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + { type: "remove", content: "old-target" }, + { type: "add", content: "replacement contents" }, + ]), + ); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test.skipIf(process.platform === "win32")( + "highlighted symlink replacements use stored link text instead of target contents", + async () => { + const { encodeDiffFrame, decodeDiffFrame } = + await import("@getpaseo/protocol/binary-frames/index"); + const cwd = await mkdtemp(join(tmpdir(), "diff-highlighted-link-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { symlink, unlink } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "file.ts"), "export const old = 1;\n"); + await writeFile(join(cwd, "target.ts"), "export const entirelyDifferentLength = 2;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await unlink(join(cwd, "file.ts")); + await symlink("target.ts", join(cwd, "file.ts")); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + try { + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual( + expect.objectContaining({ type: "add", content: "target.ts" }), + ); + const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test("files removed between listing and highlighting retain their deletion patch", async () => { + const { encodeDiffFrame, decodeDiffFrame } = + await import("@getpaseo/protocol/binary-frames/index"); + const cwd = await mkdtemp(join(tmpdir(), "diff-source-disappeared-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.ts"), "export const old = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.ts"), "export const old = 2;\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + try { + expect(comparison.files[0].entry.change).toBe("modified"); + await rm(join(cwd, "a.ts")); + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual( + expect.objectContaining({ type: "remove", content: "export const old = 1;" }), + ); + const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("a batched oversized tracked patch does not hide its small sibling", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-batch-budget-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "before\n"); + await writeFile(join(cwd, "b.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.txt"), "oversized\n".repeat(1024 * 1024)); + await writeFile(join(cwd, "b.txt"), "after\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + try { + expect(await comparison.read(comparison.files[0])).toEqual({ kind: "too_large" }); + const sibling = await comparison.read(comparison.files[1]); + if (sibling.kind !== "ready") throw new Error("Expected readable sibling"); + expect(sibling.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({ + type: "add", + content: "after", + }); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("replacing a submodule with a regular file preserves its replacement body", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-gitlink-replacement-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { mkdir } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await mkdir(join(cwd, "sub")); + git("-C", "sub", "init", "-b", "main"); + git("-C", "sub", "config", "user.name", "Test"); + git("-C", "sub", "config", "user.email", "test@example.com"); + await writeFile(join(cwd, "sub", "a.txt"), "base\n"); + git("-C", "sub", "add", "."); + git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-m", "base"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "record submodule"); + await rm(join(cwd, "sub"), { recursive: true, force: true }); + await writeFile(join(cwd, "sub"), "replacement contents\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + try { + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual( + expect.objectContaining({ type: "add", content: "replacement contents" }), + ); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.each([ + { ignoreWhitespace: false, after: "export const value = 2;\n", files: ["a.ts"] }, + { ignoreWhitespace: true, after: "export const value = 1;\n", files: [] }, +])( + "interpretation checks ignore content edits but detect new binary attributes, whitespace=$ignoreWhitespace", + async ({ ignoreWhitespace, after, files }) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-interpretation-check-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.autocrlf", "false"); + await writeFile(join(cwd, "a.ts"), "export const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "a.ts"), after); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const compare = { + cwd, + base: { kind: "rev" as const, rev: base }, + head: { kind: "rev" as const, rev: git("rev-parse", "HEAD") }, + ignoreWhitespace, + highlight: false, + }; + const comparison = await listComparison(compare); + try { + expect(comparison.files.map((file) => file.entry.path)).toEqual(files); + expect(await comparison.hasInterpretationChanged()).toBe(false); + await writeFile(join(cwd, "a.ts"), "uncommitted content\n"); + expect(await comparison.hasInterpretationChanged()).toBe(false); + await writeFile(join(cwd, ".gitattributes"), "a.ts -diff\n"); + expect(await comparison.hasInterpretationChanged()).toBe(true); + const fresh = await listComparison(compare); + try { + expect(fresh.files.map((file) => file.entry)).toEqual([ + expect.objectContaining({ path: "a.ts", content: "binary" }), + ]); + } finally { + await fresh.close(); + } + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/diff/git.ts b/packages/server/src/server/diff/git.ts new file mode 100644 index 0000000000..c58c1de181 --- /dev/null +++ b/packages/server/src/server/diff/git.ts @@ -0,0 +1,431 @@ +import { parseGitRevParsePath } from "../../utils/git-rev-parse-path.js"; +import { readGitInputs } from "./inputs.js"; +import { Patches } from "./patches.js"; +import { HighlightSources } from "./sources.js"; +import { + prepareAllChangesIndex, + PER_FILE_DIFF_MAX_BYTES, + GIT_PATCH_FORMAT_ARGS, + inspectUntrackedFile, + snapshotComparisonIndex, + resolveCheckoutDiffBase, + type CheckoutDiffCompare, +} from "../../utils/checkout-git.js"; +import { lstat, readFile, readlink } from "node:fs/promises"; +import type { BigIntStats } from "node:fs"; +import { resolve } from "node:path"; +import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages"; +import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index"; +import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js"; +import { parseDiff, highlightDiffWithFileContent } from "../utils/diff-highlighter.js"; + +export type Compare = Omit & { + checkoutBase?: { compare: CheckoutDiffCompare; paseoHome: string }; +}; +export interface ListedFile { + entry: DiffEntry; + identity: string; + source: { kind: "tracked" } | { kind: "untracked"; size: number }; +} +interface ResolvedComparison { + baseSha: string; + headSha: string; + mergeBaseSha: string; + comparisonBaseSha: string; +} +export type DiffFileRead = { kind: "ready"; body: DiffBody; bytes: number } | { kind: "too_large" }; +export interface Comparison extends Pick< + ResolvedComparison, + "baseSha" | "headSha" | "mergeBaseSha" +> { + files: ListedFile[]; + metrics: { highlightCalls: number }; + close(): Promise; + selectBodies(paths: ReadonlySet): void; + hasInterpretationChanged(): Promise; + read( + file: ListedFile, + budget?: { remainingBytes?: number; maxPatchBytes?: number }, + ): Promise; +} +export class DiffReadError extends Error { + constructor( + readonly code: "INVALID_COMPARE" | "NOT_GIT_REPO" | "REF_NOT_FOUND", + message: string, + ) { + super(message); + } +} +const EMPTY = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +export function validateCompare(compare: Compare): void { + if (compare.head.kind === "empty") + throw new DiffReadError("INVALID_COMPARE", "The head cannot be empty"); + if (compare.base.kind === "worktree") + throw new DiffReadError("INVALID_COMPARE", "The working tree cannot be the base"); + if (compare.base.kind === "index" && compare.head.kind !== "worktree") + throw new DiffReadError("INVALID_COMPARE", "An index base requires a working tree head"); + if (compare.mergeBase && compare.base.kind !== "rev") + throw new DiffReadError("INVALID_COMPARE", "Merge base requires a revision base"); +} +export async function worktreeRoot(cwd: string): Promise { + const result = await runGitCommand(["rev-parse", "--show-toplevel"], { + cwd, + acceptExitCodes: [0, 128], + }); + if (result.exitCode !== 0) throw new DiffReadError("NOT_GIT_REPO", "Not a git repository"); + const root = parseGitRevParsePath(result.stdout); + if (root === null) throw new DiffReadError("NOT_GIT_REPO", "Git returned no working tree root"); + return resolve(root); +} +async function resolveEndpoint( + cwd: string, + endpoint: DiffEndpoint, + indexFile: string | null, +): Promise { + if (endpoint.kind === "empty") return EMPTY; + if (endpoint.kind === "index") { + if (indexFile === null) throw new Error("Index comparisons require an index snapshot"); + const result = await runGitCommand(["write-tree"], { + cwd, + envOverlay: { GIT_INDEX_FILE: indexFile }, + }); + return result.stdout.trim(); + } + const ref = endpoint.kind === "rev" ? endpoint.rev : "HEAD"; + const result = await runGitCommand( + ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], + { + cwd, + acceptExitCodes: [0, 128], + }, + ); + if (result.exitCode === 0) return result.stdout.trim(); + if (ref === "HEAD") { + const unborn = await runGitCommand(["rev-parse", "--verify", "HEAD"], { + cwd, + acceptExitCodes: [0, 128], + }); + if (unborn.exitCode !== 0) return EMPTY; + } + throw new DiffReadError("REF_NOT_FOUND", `Revision not found: ${ref}`); +} +function parseStats( + text: string, +): Map { + const fields = text.split("\0"); + const stats = new Map(); + for (let index = 0; index < fields.length - 1; index++) { + const line = fields[index]; + const tab = line.indexOf("\t"); + const second = line.indexOf("\t", tab + 1); + const add = line.slice(0, tab); + const del = line.slice(tab + 1, second); + let path = line.slice(second + 1); + if (!path) { + index++; + path = fields[++index]; + } + stats.set(path, { + additions: Number(add) || 0, + deletions: Number(del) || 0, + binary: add === "-", + }); + } + return stats; +} +export async function listComparison(compare: Compare): Promise { + if (compare.checkoutBase) { + const selected = await resolveCheckoutDiffBase(compare.cwd, compare.checkoutBase.compare, { + paseoHome: compare.checkoutBase.paseoHome, + }); + if (!selected) throw new DiffReadError("REF_NOT_FOUND", "Comparison base is unavailable"); + compare = { ...compare, base: { kind: "rev", rev: selected } }; + } + validateCompare(compare); + const cwd = compare.cwd; + let privateIndex: Awaited> = null; + try { + if (compare.base.kind === "index" || compare.head.kind === "index") + privateIndex = await snapshotComparisonIndex(cwd); + const indexFile = privateIndex ? privateIndex.indexFile : null; + const baseSha = await resolveEndpoint(cwd, compare.base, indexFile); + const headSha = await resolveEndpoint(cwd, compare.head, indexFile); + let mergeBaseSha = ""; + let comparisonBaseSha = baseSha; + if (compare.mergeBase) { + const headCommit = + compare.head.kind === "index" + ? await resolveEndpoint(cwd, { kind: "rev", rev: "HEAD" }, indexFile) + : headSha; + if (headCommit === EMPTY) comparisonBaseSha = EMPTY; + else if (baseSha !== EMPTY) { + const result = await runGitCommand(["merge-base", baseSha, headCommit], { + cwd, + acceptExitCodes: [0, 1], + }); + mergeBaseSha = result.stdout.trim(); + if (mergeBaseSha) comparisonBaseSha = mergeBaseSha; + } + } + if (compare.head.kind === "worktree" && compare.base.kind !== "index") + privateIndex = await prepareAllChangesIndex(cwd, comparisonBaseSha); + return await readComparisonList( + compare, + { baseSha, headSha, mergeBaseSha, comparisonBaseSha }, + privateIndex, + ); + } catch (error) { + await privateIndex?.cleanup(); + throw error; + } +} + +async function readComparisonList( + compare: Compare, + resolved: ResolvedComparison, + privateIndex: Awaited>, +): Promise { + const { baseSha, headSha, mergeBaseSha, comparisonBaseSha } = resolved; + const cwd = compare.cwd; + const args = diffArguments(compare, comparisonBaseSha, headSha); + const options: GitCommandOptions = { + cwd, + envOverlay: { + GIT_OPTIONAL_LOCKS: "0", + ...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}), + }, + }; + const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles( + compare, + args, + options, + ); + if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options))); + files.sort((a, b) => a.entry.path.localeCompare(b.entry.path)); + for (const file of files) interpretationPaths.add(file.entry.path); + const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options); + for (const file of files) + file.identity += JSON.stringify([ + gitInputs.identities.get(file.entry.path), + gitInputs.identities.get(file.entry.oldPath ?? file.entry.path), + ]); + const metrics = { highlightCalls: 0 }; + const patches = new Patches(args, options, files); + const sources = new HighlightSources({ + cwd, + baseSha: comparisonBaseSha, + headSha: compare.head.kind === "worktree" ? null : headSha, + files, + }); + return { + baseSha, + headSha, + mergeBaseSha, + files, + metrics, + selectBodies: (paths) => patches.select(paths), + hasInterpretationChanged: gitInputs.hasInterpretationChanged, + close: async () => { + await privateIndex?.cleanup(); + }, + async read(file, budget = {}) { + const maxPatchBytes = budget.maxPatchBytes ?? PER_FILE_DIFF_MAX_BYTES; + const remainingBytes = budget.remainingBytes ?? maxPatchBytes; + if (file.source.kind === "untracked" && file.source.size > maxPatchBytes) { + return { kind: "too_large" }; + } + const result = + file.source.kind === "untracked" + ? await runGitCommand( + ["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path], + { + ...options, + acceptExitCodes: [0, 1], + maxOutputBytes: maxPatchBytes + 1, + }, + ) + : await patches.read(file, maxPatchBytes); + const bytes = Buffer.byteLength(result.stdout); + if (result.truncated || bytes > remainingBytes || bytes > maxPatchBytes) + return { kind: "too_large" }; + // File-type changes have a deletion and addition section for the same path. + const sections = parseDiff(result.stdout).filter( + (candidate) => candidate.path === file.entry.path, + ); + if (!sections.length) + return { kind: "ready", body: { path: file.entry.path, hunks: [] }, bytes }; + const parsed = { + path: file.entry.path, + isNew: file.entry.change === "added", + isDeleted: file.entry.change === "deleted", + additions: file.entry.additions, + deletions: file.entry.deletions, + hunks: sections.flatMap((section) => section.hunks), + }; + let highlighted = parsed; + if (compare.highlight !== false) { + metrics.highlightCalls++; + const context = await sources.read(file); + highlighted = await highlightDiffWithFileContent(parsed, cwd, context); + } + const hunks = highlighted.hunks.map((hunk) => ({ + oldStart: hunk.oldStart, + oldCount: hunk.oldCount, + newStart: hunk.newStart, + newCount: hunk.newCount, + lines: hunk.lines.map((line) => { + if (!line.tokens) return { type: line.type, content: line.content }; + return { + type: line.type, + content: line.content, + runs: line.tokens + .filter((token) => token.text.length > 0) + .map((token) => ({ style: token.style, length: token.text.length })), + }; + }), + })); + return { kind: "ready", body: { path: file.entry.path, hunks }, bytes }; + }, + }; +} + +async function readTrackedFiles( + compare: Compare, + args: string[], + options: GitCommandOptions, +): Promise<{ files: ListedFile[]; worktreeFiles: Set; interpretationPaths: Set }> { + const [raw, stat] = await Promise.all([ + runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options), + runGitCommand([...args, "--numstat", "-z"], options), + ]); + if (raw.truncated || stat.truncated) + throw new Error("Diff entry list exceeded the git output budget"); + const stats = parseStats(stat.stdout); + const fields = raw.stdout.split("\0"); + const files: ListedFile[] = []; + const worktreeFiles = new Set(); + const interpretationPaths = new Set(); + for (let index = 0; index < fields.length - 1; index++) { + const header = fields[index].split(" "); + const code = header[4]?.[0]; + let path = fields[++index]; + let oldPath: string | null = null; + if (code === "R" || code === "C") { + oldPath = path; + path = fields[++index]; + } + interpretationPaths.add(path); + if (oldPath !== null) interpretationPaths.add(oldPath); + const counts = stats.get(path); + // With -w, a raw metadata entry need not have a numstat record. + if (!counts && compare.ignoreWhitespace) continue; + if (!counts) throw new Error(`Missing diff statistics for ${path}`); + let change: DiffEntry["change"] = "modified"; + if (code === "A") change = "added"; + else if (code === "D") change = "deleted"; + else if (oldPath !== null) change = "renamed"; + let identity = header.join(" "); + if (compare.head.kind === "worktree" && code !== "D") { + const worktree = await readWorktreeIdentity(path, header, options); + identity += `:${worktree.identity}`; + if (worktree.regular && !counts.binary) worktreeFiles.add(path); + } + + files.push({ + entry: { + path, + oldPath, + change, + content: counts.binary ? "binary" : "text", + additions: counts.additions, + deletions: counts.deletions, + body: !counts.binary, + }, + identity, + source: { kind: "tracked" }, + }); + } + return { files, worktreeFiles, interpretationPaths }; +} + +async function readWorktreeIdentity( + path: string, + header: string[], + options: GitCommandOptions, +): Promise<{ identity: string; regular: boolean }> { + const value = await lstat(resolve(options.cwd, path), { bigint: true }); + let identity = fileStatIdentity(value); + if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) { + const submodule = await runGitCommand( + ["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], + { cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } }, + ); + identity += `:${submodule.stdout}`; + } + return { identity, regular: value.isFile() }; +} + +function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] { + const revisions: string[] = []; + if (compare.base.kind !== "index") revisions.push(baseSha); + if (compare.head.kind === "rev") revisions.push(headSha); + if (compare.head.kind === "index") revisions.unshift("--cached"); + const args = ["diff", ...GIT_PATCH_FORMAT_ARGS, "--no-textconv", "-M", ...revisions]; + if (compare.ignoreWhitespace) args.push("-w"); + return args; +} + +async function readUntrackedText( + path: string, + symlink: boolean, + omitted: boolean, +): Promise { + if (symlink) return Buffer.from(await readlink(path)); + if (omitted) return Buffer.alloc(0); + return readFile(path); +} + +async function listUntrackedFiles(options: GitCommandOptions): Promise { + const cwd = options.cwd; + const files: ListedFile[] = []; + const untracked = await runGitCommand( + ["ls-files", "--others", "--exclude-standard", "-z"], + options, + ); + for (const path of untracked.stdout.split("\0").filter(Boolean)) { + const value = await lstat(resolve(cwd, path), { bigint: true }); + if (!value.isFile() && !value.isSymbolicLink()) continue; + const symlink = value.isSymbolicLink(); + let binary = false; + let omitted = false; + if (!symlink) { + const inspected = await inspectUntrackedFile(cwd, path); + binary = inspected.stat?.isBinary === true; + omitted = inspected.truncated; + } + const content = await readUntrackedText(resolve(cwd, path), symlink, binary || omitted); + const additions = + binary || content.length === 0 + ? 0 + : content.toString("utf8").split("\n").length - Number(content.at(-1) === 10); + files.push({ + entry: { + path, + oldPath: null, + change: "added", + content: binary ? "binary" : "text", + additions, + deletions: 0, + body: !binary, + }, + identity: fileStatIdentity(value), + source: { kind: "untracked", size: Number(value.size) }, + }); + } + return files; +} + +function fileStatIdentity(value: BigIntStats): string { + // Timestamp-preserving copies still change file status or replace the inode. + return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; +} diff --git a/packages/server/src/server/diff/index.ts b/packages/server/src/server/diff/index.ts new file mode 100644 index 0000000000..6c9a537f86 --- /dev/null +++ b/packages/server/src/server/diff/index.ts @@ -0,0 +1,487 @@ +import { TOTAL_DIFF_MAX_BYTES } from "../../utils/checkout-git.js"; +import { DEFAULT_MAX_OUTPUT_BYTES, runGitCommand } from "../../utils/run-git-command.js"; +import { expandTilde, createRealpathAwarePathMatcher } from "../../utils/path.js"; +import { packBodies } from "./batches.js"; +import { setImmediate } from "node:timers/promises"; +import { + DiffOpcode, + materializeDiffFile, + type DiffFrame, + type DiffEntry, + type DiffBody, +} from "@getpaseo/protocol/binary-frames/index"; +import type { + DiffError, + DiffEndpoint, + ParsedDiffFile, + CheckoutCommitFileDiffRequest, +} from "@getpaseo/protocol/messages"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; +import { + listComparison, + validateCompare, + worktreeRoot, + DiffReadError, + type Compare, + type Comparison, +} from "./git.js"; +import { DiffDependencyObserver } from "./dependencies.js"; + +interface RecordCache { + identity: string; + body: DiffBody; + bytes: number; +} +interface Listener { + emit(frame: DiffFrame): Promise; + active: boolean; + delivery: Promise; +} +interface Target { + key: string; + compare: Compare; + listeners: Set; + revision: number; + committed: number; + cache: Map; + entries: DiffEntry[]; + resolved: Pick; + error: DiffError | null; + omitted: string[]; + refresh: Promise | null; + dirty: boolean; + checkInterpretation: (() => Promise) | null; + interpretationCheck: Promise | null; + interpretationDirty: boolean; + release: (() => Promise) | null; + opening: Promise | null; + closed: boolean; + timer: ReturnType | null; +} +export interface CommitFileRead extends Pick< + CheckoutCommitFileDiffRequest, + "cwd" | "sha" | "path" +> { + signal?: AbortSignal; +} +export interface DiffSubscription { + start(): Promise; + release(): Promise; +} +export class DiffSubscriptions { + private targets = new Map(); + private dependencies: DiffDependencyObserver; + private computations = 0; + private highlightCalls = 0; + constructor(workspaceGit: Pick) { + this.dependencies = new DiffDependencyObserver(workspaceGit); + } + getMetrics() { + return { + targets: this.targets.size, + subscriptions: [...this.targets.values()].reduce( + (sum, target) => sum + target.listeners.size, + 0, + ), + computations: this.computations, + highlightCalls: this.highlightCalls, + metadataObservers: this.dependencies.getObservationCount(), + }; + } + async readCommitFile(input: CommitFileRead): Promise { + input.signal?.throwIfAborted(); + const cwd = await worktreeRoot(expandTilde(input.cwd)); + const result = await runGitCommand( + ["rev-list", "--parents", "--max-count=1", "--end-of-options", input.sha], + { cwd }, + ); + const [head, parent] = result.stdout.trim().split(" "); + if (!head) throw new DiffReadError("REF_NOT_FOUND", `Revision not found: ${input.sha}`); + const base: DiffEndpoint = parent ? { kind: "rev", rev: parent } : { kind: "empty" }; + const comparison = await listComparison({ cwd, base, head: { kind: "rev", rev: head } }); + try { + input.signal?.throwIfAborted(); + const file = comparison.files.find((candidate) => candidate.entry.path === input.path); + if (!file || file.entry.content === "binary") return null; + comparison.selectBodies(new Set([file.entry.path])); + const record = await comparison.read(file, { maxPatchBytes: DEFAULT_MAX_OUTPUT_BYTES }); + input.signal?.throwIfAborted(); + return materializeDiffFile( + file.entry, + record.kind === "ready" ? record.body : null, + record.kind === "too_large", + ); + } finally { + await comparison.close(); + } + } + async subscribe(compare: Compare, emit: Listener["emit"]): Promise { + validateCompare(compare); + compare = { + ...compare, + cwd: await worktreeRoot(expandTilde(compare.cwd)), + mergeBase: compare.mergeBase === true, + ignoreWhitespace: compare.ignoreWhitespace === true, + highlight: compare.highlight !== false, + }; + const key = JSON.stringify([ + compare.cwd, + compare.checkoutBase + ? ["checkout", JSON.stringify(compare.checkoutBase)] + : endpointKey(compare.base), + endpointKey(compare.head), + compare.mergeBase, + compare.ignoreWhitespace, + compare.highlight, + ]); + let target = this.targets.get(key); + if (!target) { + target = { + key, + compare, + listeners: new Set(), + revision: 0, + committed: 0, + cache: new Map(), + entries: [], + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + error: null, + omitted: [], + refresh: null, + dirty: false, + checkInterpretation: null, + interpretationCheck: null, + interpretationDirty: false, + release: null, + opening: null, + closed: false, + timer: null, + }; + this.targets.set(key, target); + const captured = target; + try { + target.opening = this.dependencies + .declareDependencies( + { + cwd: compare.cwd, + refs: [compare.base, compare.head].flatMap((endpoint) => + endpoint.kind === "rev" && !/^[a-f0-9]{40,64}$/.test(endpoint.rev) + ? [endpoint.rev] + : [], + ), + index: compare.base.kind === "index" || compare.head.kind !== "rev", + worktree: compare.head.kind === "worktree", + }, + (kind) => { + if (kind === "interpretation") this.invalidateInterpretation(captured); + else this.invalidate(captured); + }, + ) + .then((release) => { + captured.release = release; + return undefined; + }); + } catch (error) { + this.targets.delete(key); + throw error; + } + } + try { + await target.opening; + } catch (error) { + target.closed = true; + this.targets.delete(key); + throw error; + } + if (target.closed) throw new Error("Diff subscription cancelled"); + const current = target; + const listener: Listener = { emit, active: false, delivery: Promise.resolve() }; + current.listeners.add(listener); + let released = false; + return { + start: async () => { + while (current.refresh) await current.refresh; + if (current.closed || released) return; + listener.active = true; + if (!current.committed) await this.refresh(current); + else { + const replay = this.replay(current, listener); + listener.delivery = replay.catch(() => {}); + await replay; + } + }, + release: async () => { + if (released) return; + released = true; + listener.active = false; + current.listeners.delete(listener); + if (current.listeners.size) return; + current.closed = true; + if (current.timer) clearTimeout(current.timer); + this.targets.delete(key); + await current.release?.(); + await current.refresh; + await current.interpretationCheck; + }, + }; + } + async invalidateForCwd(cwd: string): Promise { + let root: string; + try { + root = await worktreeRoot(expandTilde(cwd)); + } catch (error) { + if (error instanceof DiffReadError && error.code === "NOT_GIT_REPO") return; + throw error; + } + const matches = createRealpathAwarePathMatcher(root); + for (const target of this.targets.values()) + if (matches(target.compare.cwd)) this.invalidate(target); + } + private invalidateInterpretation(target: Target): void { + if (target.closed) return; + target.interpretationDirty = true; + target.interpretationCheck ??= this.checkInterpretation(target).finally(() => { + target.interpretationCheck = null; + }); + } + private async checkInterpretation(target: Target): Promise { + do { + target.interpretationDirty = false; + try { + if (!target.checkInterpretation || (await target.checkInterpretation())) { + this.invalidate(target); + return; + } + } catch { + // A normal revision owns error delivery and recovery. + this.invalidate(target); + return; + } + } while (target.interpretationDirty && !target.closed); + } + private invalidate(target: Target): void { + if (target.closed) return; + target.dirty = true; + if (target.listeners.size === 0) return; + if (target.timer) clearTimeout(target.timer); + target.timer = setTimeout(() => { + target.timer = null; + void this.refresh(target); + }, 150); + } + private async emit(target: Target, frame: DiffFrame): Promise { + await Promise.all( + [...target.listeners] + .filter((listener) => listener.active) + .map((listener) => { + const sent = listener.delivery.then(() => listener.emit(frame)); + listener.delivery = sent.catch(() => {}); + return sent; + }), + ); + await setImmediate(); + } + private async replay(target: Target, listener: Listener): Promise { + const entries = target.entries.map((entry) => ({ ...entry, body: entry.content !== "binary" })); + await listener.emit({ + opcode: DiffOpcode.Begin, + slot: 0, + revision: target.committed, + baseRevision: 0, + ...target.resolved, + entries, + }); + const bodies: DiffBody[] = []; + for (const entry of entries) { + const cached = target.cache.get(entry.path); + if (entry.body && cached) bodies.push(cached.body); + } + for await (const files of packBodies(bodies, target.committed)) { + await listener.emit({ + opcode: DiffOpcode.Bodies, + slot: 0, + revision: target.committed, + files, + }); + await setImmediate(); + } + await listener.emit({ + opcode: DiffOpcode.End, + slot: 0, + revision: target.committed, + diffTooLarge: target.omitted.length > 0, + omitted: target.omitted, + error: target.error, + }); + } + private async refresh(target: Target): Promise { + if (target.refresh || target.closed) return target.refresh ?? undefined; + target.refresh = this.run(target).finally(() => { + target.refresh = null; + }); + return target.refresh; + } + private async run(target: Target): Promise { + do { + target.dirty = false; + const revision = ++target.revision; + let comparison: Comparison | null = null; + let began = false; + try { + this.computations++; + comparison = await listComparison(target.compare); + target.checkInterpretation = comparison.hasInterpretationChanged; + const resolved = { + baseSha: comparison.baseSha, + headSha: comparison.headSha, + mergeBaseSha: comparison.mergeBaseSha, + }; + const tooManyFiles = comparison.files.length > 10000; + const entries = comparison.files.map((file) => ({ + ...file.entry, + body: tooManyFiles + ? file.entry.content === "text" + : needsBody(target, file.entry, file.identity), + })); + await this.emit(target, { + opcode: DiffOpcode.Begin, + slot: 0, + revision, + baseRevision: target.error ? 0 : target.committed, + ...resolved, + entries, + }); + began = true; + const cache = new Map(); + const omitted: string[] = []; + const state = { cache, omitted, total: 0 }; + const records = this.readBodies(target, comparison, entries, state); + for await (const files of packBodies(records, revision)) { + if (isInterrupted(target)) break; + await this.emit(target, { opcode: DiffOpcode.Bodies, slot: 0, revision, files }); + } + if (isInterrupted(target)) continue; + await this.emit(target, { + opcode: DiffOpcode.End, + slot: 0, + revision, + omitted, + diffTooLarge: omitted.length > 0, + error: null, + }); + target.committed = revision; + target.resolved = resolved; + target.entries = entries; + target.cache = cache; + target.omitted = omitted; + target.error = null; + } catch (error) { + const failure = diffError(error); + if (!began) + await this.emit(target, { + opcode: DiffOpcode.Begin, + slot: 0, + revision, + baseRevision: 0, + baseSha: "", + headSha: "", + mergeBaseSha: "", + entries: [], + }); + await this.emit(target, { + opcode: DiffOpcode.End, + slot: 0, + revision, + diffTooLarge: false, + omitted: [], + error: failure, + }); + target.error = failure; + target.committed = revision; + target.entries = []; + target.cache.clear(); + target.omitted = []; + } finally { + if (comparison) this.highlightCalls += comparison.metrics.highlightCalls; + await comparison?.close(); + } + } while (target.dirty && !target.closed); + } + private async *readBodies( + target: Target, + comparison: Comparison, + entries: DiffEntry[], + state: { cache: Map; omitted: string[]; total: number }, + ): AsyncGenerator { + if (entries.length > 10000) { + for (const entry of entries) if (entry.content === "text") state.omitted.push(entry.path); + return; + } + comparison.selectBodies( + new Set(entries.filter((entry) => entry.body).map((entry) => entry.path)), + ); + for (let index = 0; index < comparison.files.length; index++) { + if (isInterrupted(target)) return; + const file = comparison.files[index]; + const entry = entries[index]; + if (entry.content === "binary") continue; + let record = target.cache.get(entry.path); + if (entry.body || !record) { + const read = await comparison.read(file, { + remainingBytes: TOTAL_DIFF_MAX_BYTES - state.total, + }); + if (read.kind === "too_large") { + state.omitted.push(entry.path); + continue; + } + record = { identity: file.identity, body: read.body, bytes: read.bytes }; + } + if (exceedsBudget(state.total, record.bytes)) { + state.omitted.push(entry.path); + continue; + } + state.total += record.bytes; + state.cache.set(entry.path, record); + if (entry.body) yield record.body; + } + } + async close(): Promise { + for (const target of this.targets.values()) { + target.closed = true; + if (target.timer) clearTimeout(target.timer); + await target.opening; + await target.release?.(); + await target.refresh; + await target.interpretationCheck; + } + this.targets.clear(); + await this.dependencies.close(); + } +} + +function isInterrupted(target: Target): boolean { + return target.closed || target.dirty; +} +function exceedsBudget(total: number, bytes: number): boolean { + return total + bytes > TOTAL_DIFF_MAX_BYTES; +} +function needsBody(target: Target, entry: DiffEntry, identity: string): boolean { + return ( + entry.content === "text" && + (target.error !== null || target.cache.get(entry.path)?.identity !== identity) + ); +} + +function diffError(error: unknown): DiffError { + return { + code: error instanceof DiffReadError ? error.code : "UNKNOWN", + message: error instanceof Error ? error.message : String(error), + }; +} + +export { DiffSession } from "./session.js"; + +function endpointKey(endpoint: DiffEndpoint): string[] { + if (endpoint.kind === "rev") return [endpoint.kind, endpoint.rev]; + return [endpoint.kind]; +} diff --git a/packages/server/src/server/diff/inputs.ts b/packages/server/src/server/diff/inputs.ts new file mode 100644 index 0000000000..84a5a7f446 --- /dev/null +++ b/packages/server/src/server/diff/inputs.ts @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; +import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js"; + +interface Interpretation { + config: string; + attributes: Map; + fingerprint: string; +} + +// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git +// for effective attributes/config and filtered blob identities without writing objects. +export async function readGitInputs( + paths: string[], + worktreeFiles: Set, + options: GitCommandOptions, +) { + const regular = paths.filter((path) => worktreeFiles.has(path)); + const [interpretation, blobs] = await Promise.all([ + readInterpretation(paths, options), + regular.length + ? runGitCommand(["hash-object", "--stdin-paths"], { + ...options, + input: regular.map(quotePath).join("\n") + "\n", + }) + : null, + ]); + if (blobs?.truncated) throw new Error("Git input identity exceeded the output budget"); + const hashes = blobs?.stdout.trim().split(/\r?\n/) ?? []; + if (hashes.length !== regular.length) throw new Error("Git returned incomplete blob identities"); + const blobByPath = new Map(regular.map((path, index) => [path, hashes[index]])); + const identities = new Map( + paths.map((path) => [ + path, + fingerprint([ + interpretation.config, + interpretation.attributes.get(path) ?? [], + blobByPath.get(path) ?? null, + ]), + ]), + ); + return { + identities, + hasInterpretationChanged: interpretationCheck(paths, options.cwd, interpretation.fingerprint), + }; +} + +function interpretationCheck( + paths: string[], + cwd: string, + baseline: string, +): () => Promise { + // Retain only the input description, never a comparison's patch/source pages. + return async () => { + const current = await readInterpretation(paths, { + cwd, + envOverlay: { GIT_OPTIONAL_LOCKS: "0" }, + }); + return current.fingerprint !== baseline; + }; +} + +async function readInterpretation( + paths: string[], + options: GitCommandOptions, +): Promise { + if (!paths.length) return { config: "", attributes: new Map(), fingerprint: "" }; + const [config, result] = await Promise.all([ + runGitCommand(["config", "--null", "--list"], options), + runGitCommand(["check-attr", "--all", "-z", "--stdin"], { + ...options, + input: paths.join("\0") + "\0", + }), + ]); + if (config.truncated || result.truncated) + throw new Error("Git input identity exceeded the output budget"); + const attributes = new Map(); + const fields = result.stdout.split("\0"); + if (fields.at(-1) === "") fields.pop(); + if (fields.length % 3) throw new Error("Git returned invalid attributes"); + for (let index = 0; index < fields.length; index += 3) { + const values = attributes.get(fields[index]) ?? []; + values.push(JSON.stringify([fields[index + 1], fields[index + 2]])); + attributes.set(fields[index], values); + } + for (const values of attributes.values()) values.sort(); + const configIdentity = createHash("sha256").update(config.stdout).digest("hex"); + return { + config: configIdentity, + attributes, + fingerprint: fingerprint([ + configIdentity, + paths.map((path) => [path, attributes.get(path) ?? []]), + ]), + }; +} + +function fingerprint(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function quotePath(path: string): string { + let quoted = '"'; + for (const byte of Buffer.from(path)) { + quoted += + byte >= 32 && byte <= 126 && byte !== 34 && byte !== 92 + ? String.fromCharCode(byte) + : `\\${byte.toString(8).padStart(3, "0")}`; + } + return quoted + '"'; +} diff --git a/packages/server/src/server/diff/patches.ts b/packages/server/src/server/diff/patches.ts new file mode 100644 index 0000000000..ef5dbc08c1 --- /dev/null +++ b/packages/server/src/server/diff/patches.ts @@ -0,0 +1,80 @@ +import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js"; +import { parseDiff } from "../utils/diff-highlighter.js"; +import type { ListedFile } from "./git.js"; + +interface Patch { + stdout: string; + truncated: boolean; +} + +// Acquire one bounded page lazily. Highlighting and delivery remain per file. +export class Patches { + private files: ListedFile[]; + private pending = new Map>>(); + constructor( + private args: string[], + private options: GitCommandOptions, + files: ListedFile[], + ) { + this.files = files.filter((file) => file.source.kind === "tracked" && file.entry.body); + } + select(paths: ReadonlySet): void { + this.files = this.files.filter((file) => paths.has(file.entry.path)); + } + async read(file: ListedFile, maxBytes: number): Promise { + const existing = this.pending.get(file.entry.path); + if (existing) return (await existing).get(file.entry.path) ?? { stdout: "", truncated: false }; + const renamed = file.entry.oldPath !== null; + const candidates = this.files.filter( + (candidate) => (candidate.entry.oldPath !== null) === renamed, + ); + const index = candidates.indexOf(file); + const page = renamed || index < 0 ? [file] : candidates.slice(index, index + 64); + this.pending.clear(); + const reading = this.load(page, maxBytes); + for (const candidate of page) this.pending.set(candidate.entry.path, reading); + return (await reading).get(file.entry.path) ?? { stdout: "", truncated: false }; + } + + private async load(files: ListedFile[], maxBytes: number): Promise> { + const paths = [ + ...new Set( + files.flatMap((file) => + file.entry.oldPath === null ? [file.entry.path] : [file.entry.oldPath, file.entry.path], + ), + ), + ]; + const result = await runGitCommand( + [ + ...this.args, + ...(files[0].entry.oldPath !== null ? ["--diff-filter=RC"] : []), + "--patch", + "--", + ...paths.map((path) => `:(literal)${path}`), + ], + { + ...this.options, + acceptExitCodes: [0, 1], + maxOutputBytes: (files.length === 1 ? maxBytes : 8 * maxBytes) + 1, + }, + ); + if (files.length === 1) return new Map([[files[0].entry.path, result]]); + if (result.truncated) { + const middle = Math.ceil(files.length / 2); + return new Map([ + ...(await this.load(files.slice(0, middle), maxBytes)), + ...(await this.load(files.slice(middle), maxBytes)), + ]); + } + const patches = new Map(); + for (const section of result.stdout.split(/(?=^diff --git )/m).filter(Boolean)) { + const hunkStart = section.indexOf("\n@@ "); + const parsed = parseDiff(hunkStart < 0 ? section : section.slice(0, hunkStart))[0]; + if (!parsed) throw new Error("Git returned an invalid patch header"); + const previous = patches.get(parsed.path)?.stdout ?? ""; + const stdout = previous + section; + patches.set(parsed.path, { stdout, truncated: Buffer.byteLength(stdout) > maxBytes }); + } + return patches; + } +} diff --git a/packages/server/src/server/diff/session.test.ts b/packages/server/src/server/diff/session.test.ts new file mode 100644 index 0000000000..3ffc0a40e5 --- /dev/null +++ b/packages/server/src/server/diff/session.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { + decodeDiffFrame, + DiffOpcode, + type DiffFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import { DiffSession, DiffSubscriptions } from "./index.js"; +import { createFileObserver } from "../file-observer/index.js"; +import { SessionDelivery } from "../session/owned-subscriptions/index.js"; + +test("a failed physical send releases that subscriber while its sibling completes the revision", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-send-failure-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, changed) { + const watch = await observer.subscribe(root, () => changed(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await watch.unsubscribe(); + }, + }; + }, + }); + const first = {}; + const second = {}; + let failFirst = false; + const firstFrames: DiffFrame[] = []; + const secondFrames: DiffFrame[] = []; + const delivery = new SessionDelivery( + () => {}, + async (source, bytes) => { + if (source === first && failFirst) throw new Error("Physical send failed"); + const frame = decodeDiffFrame(bytes); + const received = source === first ? firstFrames : secondFrames; + received.push(frame); + }, + ); + delivery.attach(first, true); + delivery.attach(second, true); + const session = new DiffSession(subscriptions, pino({ level: "silent" })); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "x.ts"), "export const value = 1;\n"); + for (const [source, requestId] of [ + [first, "first"], + [second, "second"], + ] as const) { + const message = { + type: "diff.subscribe.request" as const, + requestId, + cwd, + base: { kind: "rev" as const, rev: "HEAD" }, + head: { kind: "worktree" as const }, + }; + await delivery.request(source, message, () => session.subscribe(message, delivery, () => {})); + const received = source === first ? firstFrames : secondFrames; + await waitForEnd(received); + } + secondFrames.length = 0; + failFirst = true; + await writeFile(join(cwd, "x.ts"), "export const value = 2;\n"); + await waitForEnd(secondFrames); + const begins = secondFrames + .filter((frame) => frame.opcode === DiffOpcode.Begin) + .map((frame) => frame.revision); + expect(new Set(begins).size).toBe(begins.length); + expect(secondFrames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + expect(delivery.subscriptionIds(first, "diffs")).toEqual([]); + expect(delivery.subscriptionIds(second, "diffs")).toHaveLength(1); + } finally { + await delivery.close(); + await subscriptions.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30000); + +async function waitForEnd(frames: DiffFrame[]): Promise { + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); +} diff --git a/packages/server/src/server/diff/session.ts b/packages/server/src/server/diff/session.ts new file mode 100644 index 0000000000..a965b2fa91 --- /dev/null +++ b/packages/server/src/server/diff/session.ts @@ -0,0 +1,86 @@ +import type { Logger } from "pino"; +import { encodeDiffFrame } from "@getpaseo/protocol/binary-frames/index"; +import type { + DiffSubscribeRequest, + SessionOutboundMessage, + DiffError, +} from "@getpaseo/protocol/messages"; +import type { SessionDelivery, OwnedSubscription } from "../session/owned-subscriptions/index.js"; +import { DiffReadError } from "./git.js"; +import type { DiffSubscriptions, DiffSubscription } from "./index.js"; + +export class DiffSession { + private slots = new Set(); + constructor( + private subscriptions: DiffSubscriptions, + private logger: Pick, + ) {} + async subscribe( + msg: DiffSubscribeRequest, + ownership: SessionDelivery, + emit: (message: SessionOutboundMessage) => void, + ): Promise { + const slot = Array.from({ length: 256 }, (_, value) => value).find( + (value) => !this.slots.has(value), + ); + if (slot === undefined) { + this.reject(msg, emit, { + code: "TOO_MANY_SUBSCRIPTIONS", + message: "Too many diff subscriptions", + }); + return; + } + this.slots.add(slot); + let subscription: DiffSubscription | null = null; + const owner = ownership.begin("diffs", undefined, async () => { + this.slots.delete(slot); + await subscription?.release(); + }); + try { + subscription = await this.subscriptions.subscribe(msg, async (frame) => { + if (owner.signal.aborted) return; + const bytes = encodeDiffFrame({ ...frame, slot }); + try { + await owner.emitBinaryAndWait(bytes); + } catch (error) { + void this.stopFailedSource(owner, error); + } + }); + if (owner.signal.aborted) { + await subscription.release(); + return; + } + emit({ + type: "diff.subscribe.response", + payload: { requestId: msg.requestId, subscriptionId: owner.responseId, slot, error: null }, + }); + // Start outside the request context: the owned subscription carries delivery authority. + void subscription.start().catch((error) => this.stopFailedSource(owner, error)); + } catch (error) { + await owner.release(); + const code = error instanceof DiffReadError ? error.code : "UNKNOWN"; + this.reject(msg, emit, { + code, + message: error instanceof Error ? error.message : String(error), + }); + } + } + private async stopFailedSource(owner: OwnedSubscription, error: unknown): Promise { + if (!owner.signal.aborted) this.logger.debug({ err: error }, "Diff frame delivery failed"); + try { + await owner.release(); + } catch (failure) { + this.logger.error({ err: failure }, "Failed to release diff subscription"); + } + } + private reject( + msg: DiffSubscribeRequest, + emit: (message: SessionOutboundMessage) => void, + error: DiffError, + ): void { + emit({ + type: "diff.subscribe.response", + payload: { requestId: msg.requestId, subscriptionId: "", slot: 0, error }, + }); + } +} diff --git a/packages/server/src/server/diff/sources.ts b/packages/server/src/server/diff/sources.ts new file mode 100644 index 0000000000..776303a9eb --- /dev/null +++ b/packages/server/src/server/diff/sources.ts @@ -0,0 +1,76 @@ +import { lstat, readlink } from "node:fs/promises"; +import { resolve } from "node:path"; +import { readGitFileContents, readGitFileContentAtRef } from "../../utils/git-file-contents.js"; +import type { ListedFile } from "./git.js"; + +interface SourceOptions { + cwd: string; + baseSha: string; + headSha: string | null; + files: ListedFile[]; +} +interface HighlightContext { + oldFileContent: string | null; + newFileContent: string | null | undefined; +} + +// Keep one bounded page of source text; bodies are still highlighted only when requested. +export class HighlightSources { + private indices: Map; + private page = -1; + private pending: Promise | null> | null = null; + constructor(private options: SourceOptions) { + this.indices = new Map(options.files.map((file, index) => [file.entry.path, index])); + } + async read(file: ListedFile): Promise { + const index = this.indices.get(file.entry.path); + if (index === undefined) throw new Error("Unknown comparison file"); + const page = Math.floor(index / 64) * 64; + if (this.pending === null || this.page !== page) { + this.page = page; + this.pending = this.load(page); + } + const contents = await this.pending; + const oldFileContent = + file.entry.change === "added" + ? null + : await this.content(contents, this.options.baseSha, file.entry.oldPath ?? file.entry.path); + let newFileContent: string | null | undefined; + if (this.options.headSha !== null) { + newFileContent = await this.content(contents, this.options.headSha, file.entry.path); + } else if (file.entry.change !== "deleted") { + const path = resolve(this.options.cwd, file.entry.path); + newFileContent = await readWorktreeLink(path); + } + return { oldFileContent, newFileContent }; + } + private async load(start: number): Promise | null> { + const specs: string[] = []; + for (const file of this.options.files.slice(start, start + 64)) { + if (file.entry.content === "binary") continue; + if (file.entry.change !== "added") + specs.push(`${this.options.baseSha}:${file.entry.oldPath ?? file.entry.path}`); + if (this.options.headSha !== null) specs.push(`${this.options.headSha}:${file.entry.path}`); + } + return readGitFileContents(this.options.cwd, specs); + } + private async content( + contents: Map | null, + ref: string, + path: string, + ): Promise { + if (contents === null) return readGitFileContentAtRef(this.options.cwd, ref, path); + const content = contents.get(`${ref}:${path}`); + if (content === undefined) throw new Error("Missing comparison source"); + return content; + } +} + +async function readWorktreeLink(path: string): Promise { + try { + if ((await lstat(path)).isSymbolicLink()) return await readlink(path); + } catch { + // Source context is optional; files can disappear after Git reads the patch. + } + return undefined; +} diff --git a/packages/server/src/server/diff/subscriptions.test.ts b/packages/server/src/server/diff/subscriptions.test.ts new file mode 100644 index 0000000000..69457d2be7 --- /dev/null +++ b/packages/server/src/server/diff/subscriptions.test.ts @@ -0,0 +1,936 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { DiffOpcode, type DiffFrame } from "@getpaseo/protocol/binary-frames/index"; +import { DiffSubscriptions } from "./index.js"; +import { createFileObserver } from "../file-observer/index.js"; + +test("shares computation, highlights only the changed file, recovers refs and releases observations", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-subscriptions-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, onChange) { + const subscription = await observer.subscribe(root, () => onChange(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: () => { + void subscription.unsubscribe(); + }, + }; + }, + }); + const first: DiffFrame[] = []; + const second: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + for (let index = 0; index < 252; index++) + await writeFile(join(cwd, `${index}.ts`), `const value = ${index};\n`); + const compare = { + cwd, + base: { kind: "rev" as const, rev: "HEAD" }, + head: { kind: "worktree" as const }, + highlight: true, + }; + const coldStarted = performance.now(); + let lastEndAt = coldStarted; + const a = await subscriptions.subscribe(compare, async (frame) => { + first.push(frame); + if (frame.opcode === DiffOpcode.End) lastEndAt = performance.now(); + }); + await a.start(); + const coldMs = lastEndAt - coldStarted; + const before = subscriptions.getMetrics().computations; + const previousHighlightCalls = subscriptions.getMetrics().highlightCalls; + const b = await subscriptions.subscribe(compare, async (frame) => { + second.push(frame); + }); + await b.start(); + expect(subscriptions.getMetrics()).toEqual({ + targets: 1, + subscriptions: 2, + computations: before, + highlightCalls: previousHighlightCalls, + metadataObservers: 1, + }); + first.length = 0; + second.length = 0; + const refreshStarted = performance.now(); + await writeFile(join(cwd, "9.ts"), "const value = 999;\n"); + await expect + .poll(() => first.filter((frame) => frame.opcode === DiffOpcode.End).length, { + timeout: 10000, + }) + .toBeGreaterThan(0); + const begin = first.find((frame) => frame.opcode === DiffOpcode.Begin); + if (!begin || begin.opcode !== DiffOpcode.Begin) throw new Error("Missing begin"); + expect(begin.entries.filter((entry) => entry.body).map((entry) => entry.path)).toEqual([ + "9.ts", + ]); + const bodies = first.filter((frame) => frame.opcode === DiffOpcode.Bodies); + expect(bodies).toHaveLength(1); + expect(second).toEqual(first); + await expect + .poll(() => subscriptions.getMetrics().highlightCalls, { timeout: 10000 }) + .toBe(previousHighlightCalls + 1); + const artifactDirectory = join( + import.meta.dirname, + "../../../../../.artifacts/diff-subscription", + ); + await mkdir(artifactDirectory, { recursive: true }); + await writeFile( + join(artifactDirectory, "refresh-metrics.json"), + JSON.stringify( + { + files: 252, + coldMs, + oneFileRefreshMs: lastEndAt - refreshStarted, + changedBodies: bodies.length, + highlightCalls: subscriptions.getMetrics().highlightCalls - previousHighlightCalls, + }, + null, + 2, + ), + ); + await a.release(); + await b.release(); + expect(subscriptions.getMetrics().targets).toBe(0); + expect(subscriptions.getMetrics().metadataObservers).toBe(0); + const missing: DiffFrame[] = []; + const recovery = await subscriptions.subscribe( + { + ...compare, + base: { kind: "rev", rev: "refs/tags/later" }, + head: { kind: "rev", rev: "HEAD" }, + }, + async (frame) => { + missing.push(frame); + }, + ); + await recovery.start(); + expect(missing.at(-1)).toMatchObject({ + opcode: DiffOpcode.End, + error: { code: "REF_NOT_FOUND" }, + }); + missing.length = 0; + git("update-ref", "refs/tags/later", "HEAD"); + await expect + .poll( + () => missing.some((frame) => frame.opcode === DiffOpcode.End && frame.error === null), + { timeout: 10_000 }, + ) + .toBe(true); + await recovery.release(); + } finally { + await subscriptions.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 60_000); + +test("supersedes an in-flight revision at a batch boundary and ends the replacement", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-supersede-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, onChange) { + const watch = await observer.subscribe(root, () => onChange(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await watch.unsubscribe(); + }, + }; + }, + }); + const frames: DiffFrame[] = []; + let interrupted = false; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const content = + Array.from( + { length: 100 }, + (_, index) => `export const fn${index} = (value: string) => ({ value, count: ${index} });`, + ).join("\n") + "\n"; + for (let index = 0; index < 80; index++) await writeFile(join(cwd, `${index}.ts`), content); + const subscription = await subscriptions.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } }, + async (frame) => { + frames.push(frame); + if (frame.opcode === DiffOpcode.Bodies && !interrupted) { + interrupted = true; + await writeFile(join(cwd, "0.ts"), content + "export const added = true;\n"); + await subscriptions.invalidateForCwd(cwd); + } + }, + ); + await subscription.start(); + const begins = frames.filter((frame) => frame.opcode === DiffOpcode.Begin); + expect(interrupted).toBe(true); + expect(begins.length).toBeGreaterThan(1); + for (let index = 0; index < begins.length - 1; index++) { + const revision = begins[index].revision; + const ended = frames.some( + (frame) => frame.opcode === DiffOpcode.End && frame.revision === revision, + ); + const superseded = begins[index + 1].revision > revision; + expect(ended || superseded).toBe(true); + } + expect(frames.at(-1)).toMatchObject({ + opcode: DiffOpcode.End, + revision: begins.at(-1)?.revision, + error: null, + }); + await subscription.release(); + } finally { + await subscriptions.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30_000); + +test("moving, deleting and recreating a branch recovers, and staging changes the resolved index SHA", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-ref-index-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, changed) { + const watch = await observer.subscribe(root, () => changed(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await watch.unsubscribe(); + }, + }; + }, + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + git("branch", "compare"); + await writeFile(join(cwd, "x.ts"), "export const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "feature"); + const head = git("rev-parse", "HEAD"); + const branch = await subscriptions.subscribe( + { cwd, base: { kind: "rev", rev: "refs/heads/compare" }, head: { kind: "rev", rev: "HEAD" } }, + async (frame) => { + frames.push(frame); + }, + ); + await branch.start(); + frames.length = 0; + git("update-ref", "refs/heads/compare", head); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + baseSha: head, + entries: [], + }); + frames.length = 0; + git("update-ref", "-d", "refs/heads/compare"); + await expect + .poll( + () => + frames.some( + (frame) => frame.opcode === DiffOpcode.End && frame.error?.code === "REF_NOT_FOUND", + ), + { timeout: 10000 }, + ) + .toBe(true); + frames.length = 0; + git("update-ref", "refs/heads/compare", head); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End && frame.error === null), { + timeout: 10000, + }) + .toBe(true); + await branch.release(); + frames.length = 0; + const staged = await subscriptions.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "index" } }, + async (frame) => { + frames.push(frame); + }, + ); + await staged.start(); + const old = frames.find((frame) => frame.opcode === DiffOpcode.Begin); + if (!old || old.opcode !== DiffOpcode.Begin) throw new Error("Missing initial index revision"); + frames.length = 0; + await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n"); + git("add", "staged.ts"); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + const changed = frames.find((frame) => frame.opcode === DiffOpcode.Begin); + if (!changed || changed.opcode !== DiffOpcode.Begin) throw new Error("Missing staged revision"); + expect(changed.headSha).not.toBe(old.headSha); + expect(changed.entries.map((entry) => entry.path)).toEqual(["staged.ts"]); + await staged.release(); + } finally { + await subscriptions.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30000); + +test("a cached replay completes before a live refresh can depend on it", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-replay-order-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch() { + return { repoRoot: cwd, unsubscribe: async () => {} }; + }, + }); + let resume!: () => void; + let began!: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const replayBegan = new Promise((resolve) => { + began = resolve; + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "a.txt"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.txt"), "first\n"); + const compare = { + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + } as const; + const first = await subscriptions.subscribe(compare, async () => {}); + await first.start(); + const second = await subscriptions.subscribe(compare, async (frame) => { + frames.push(frame); + if (frames.length === 1) { + began(); + await paused; + } + }); + const replay = second.start(); + await replayBegan; + await writeFile(join(cwd, "a.txt"), "second\n"); + await subscriptions.invalidateForCwd(cwd); + await new Promise((resolve) => setTimeout(resolve, 250)); + resume(); + await replay; + await expect + .poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, { + timeout: 10000, + }) + .toBe(2); + let committed = 0; + for (const frame of frames) { + if (frame.opcode === DiffOpcode.Begin) expect([0, committed]).toContain(frame.baseRevision); + if (frame.opcode === DiffOpcode.End) committed = frame.revision; + } + await second.release(); + await first.release(); + } finally { + resume(); + await subscriptions.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("submodule commits invalidate cached worktree gitlink bodies", async () => { + const { lstat } = await import("node:fs/promises"); + const cwd = await mkdtemp(join(tmpdir(), "diff-gitlink-cache-")); + const sub = join(cwd, "sub"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await mkdir(sub); + git("-C", "sub", "init", "-b", "main"); + git("-C", "sub", "config", "user.name", "Test"); + git("-C", "sub", "config", "user.email", "test@example.com"); + await writeFile(join(sub, "a.txt"), "A\n"); + git("-C", "sub", "add", "."); + git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-m", "A"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "record A"); + await writeFile(join(sub, "a.txt"), "B\n"); + git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-am", "B"); + const before = await lstat(sub, { bigint: true }); + const subscription = await streams.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + frames.length = 0; + await writeFile(join(sub, "a.txt"), "C\n"); + git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-am", "C"); + const sha = git("-C", "sub", "rev-parse", "HEAD"); + expect((await lstat(sub, { bigint: true })).mtimeNs).toBe(before.mtimeNs); + await streams.invalidateForCwd(cwd); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 }) + .toBe(true); + const bodies = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(bodies.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual( + expect.objectContaining({ type: "add", content: `Subproject commit ${sha}` }), + ); + await subscription.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.each([true, false])( + "tracked=%s timestamp-preserving replacements refresh cached bodies", + async (tracked) => { + const { lstat, utimes } = await import("node:fs/promises"); + const cwd = await mkdtemp(join(tmpdir(), "diff-preserved-time-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + const path = join(cwd, "file.txt"); + const timestamp = new Date("2000-01-01T00:00:00Z"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "README"), "base\n"); + if (tracked) await writeFile(path, "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(path, "first\n"); + await utimes(path, timestamp, timestamp); + const compare = { + cwd, + base: { kind: "rev" as const, rev: "HEAD" }, + head: { kind: "worktree" as const }, + }; + const subscription = await streams.subscribe(compare, async (frame) => { + frames.push(frame); + }); + await subscription.start(); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const before = await lstat(path, { bigint: true }); + frames.length = 0; + await writeFile(path, "later\n"); + await utimes(path, timestamp, timestamp); + const after = await lstat(path, { bigint: true }); + expect(after.mtimeNs).toBe(before.mtimeNs); + expect(after.size).toBe(before.size); + expect(after.ctimeNs).not.toBe(before.ctimeNs); + await streams.invalidateForCwd(cwd); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 }) + .toBe(true); + const files = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual( + expect.objectContaining({ type: "add", content: "later" }), + ); + const { listComparison } = await import("./git.js"); + const fresh = await listComparison(compare); + try { + const record = await fresh.read(fresh.files[0]); + if (record.kind !== "ready") throw new Error("Expected readable replacement"); + expect(files).toEqual([record.body]); + } finally { + await fresh.close(); + } + await subscription.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test.each([true, false])( + "autocrlf=%s highlighted CRLF worktrees keep the revision readable", + async (autocrlf) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-crlf-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.autocrlf", String(autocrlf)); + await writeFile(join(cwd, "a.ts"), "export const value = 1;\r\n"); + await writeFile(join(cwd, "b.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.ts"), "export const value = 2;\r\n"); + await writeFile(join(cwd, "b.txt"), "after\n"); + const subscription = await streams.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const files = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(files.map((file) => file.path)).toEqual(["a.ts", "b.txt"]); + const line = files[0].hunks + .flatMap((hunk) => hunk.lines) + .find((candidate) => candidate.type === "add"); + expect(line?.content).toBe(`export const value = 2;${autocrlf ? "" : "\r"}`); + expect(line?.runs?.reduce((length, run) => length + run.length, 0)).toBe( + line?.content.length, + ); + await subscription.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test("explicit refresh accepts an equivalent worktree path alias", async () => { + const { symlink } = await import("node:fs/promises"); + const root = await mkdtemp(join(tmpdir(), "diff-refresh-alias-")); + const cwd = join(root, "repo"); + const alias = join(root, "alias"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(directory) { + return { repoRoot: directory, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + await mkdir(cwd); + await symlink(cwd, alias, "junction"); + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "a.txt"), "first\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const head = git("rev-parse", "HEAD"); + const subscription = await streams.subscribe( + { cwd, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: head } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + frames.length = 0; + await streams.invalidateForCwd(alias); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, revision: 2, error: null }); + await subscription.release(); + } finally { + await streams.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("attribute changes refresh interpreted content without rewriting the tracked file", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-attribute-input-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.autocrlf", "false"); + await writeFile(join(cwd, "file.txt"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "file.txt"), "first\r\n"); + const compare = { + cwd, + base: { kind: "rev" as const, rev: "HEAD" }, + head: { kind: "worktree" as const }, + }; + const subscription = await streams.subscribe(compare, async (frame) => { + frames.push(frame); + }); + await subscription.start(); + frames.length = 0; + await writeFile(join(cwd, ".git", "info", "attributes"), "file.txt text eol=lf\n"); + await streams.invalidateForCwd(cwd); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + const files = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual( + expect.objectContaining({ type: "add", content: "first" }), + ); + const { listComparison } = await import("./git.js"); + const fresh = await listComparison(compare); + try { + const record = await fresh.read(fresh.files[0]); + if (record.kind !== "ready") throw new Error("Expected interpreted body"); + expect(files).toEqual([record.body]); + } finally { + await fresh.close(); + } + await subscription.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("explicit refresh resolves subdirectories without refreshing a nested repository's parent", async () => { + const root = await mkdtemp(join(tmpdir(), "diff-refresh-directory-")); + const cwd = join(root, "repo"); + const directory = join(cwd, "workspace"); + const nested = join(directory, "nested"); + const git = (at: string, ...args: string[]) => + execFileSync("git", args, { cwd: at, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(at) { + return { repoRoot: at, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + await mkdir(nested, { recursive: true }); + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + await writeFile(join(cwd, "file.txt"), "before\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git(cwd, "rev-parse", "HEAD"); + await writeFile(join(cwd, "file.txt"), "after\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const head = git(cwd, "rev-parse", "HEAD"); + git(nested, "init", "-b", "main"); + git(nested, "config", "user.name", "Test"); + git(nested, "config", "user.email", "test@example.com"); + await writeFile(join(nested, "nested.txt"), "nested content\n"); + git(nested, "add", "."); + git(nested, "-c", "commit.gpgsign=false", "commit", "-m", "nested base"); + const nestedHead = git(nested, "rev-parse", "HEAD"); + const subscription = await streams.subscribe( + { cwd: directory, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: head } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + frames.length = 0; + await streams.invalidateForCwd(directory); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null, revision: 2 }); + const nestedFrames: DiffFrame[] = []; + const sibling = await streams.subscribe( + { cwd: nested, base: { kind: "empty" }, head: { kind: "rev", rev: nestedHead } }, + async (frame) => { + nestedFrames.push(frame); + }, + ); + await sibling.start(); + nestedFrames.length = 0; + const before = streams.getMetrics().computations; + await streams.invalidateForCwd(nested); + await expect + .poll(() => nestedFrames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(streams.getMetrics().computations).toBe(before + 1); + await sibling.release(); + await subscription.release(); + } finally { + await streams.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test.each(["main", "linked"] as const)( + "%s checkout observes worktree configuration and shared attributes", + async (checkout) => { + const { resolve } = await import("node:path"); + const { readFile } = await import("node:fs/promises"); + const root = await mkdtemp(join(tmpdir(), "diff-interpretation-watch-")); + let cwd = join(root, "repo"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(at) { + return { repoRoot: at, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + await mkdir(cwd); + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + const lines = Array.from( + { length: 20 }, + (_, index) => `export const value${index} = ${index};`, + ); + await writeFile(join(cwd, "a.ts"), lines.join("\n") + "\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + lines[10] = "export const value10 = 999;"; + await writeFile(join(cwd, "a.ts"), lines.join("\n") + "\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + git("config", "extensions.worktreeConfig", "true"); + if (checkout === "linked") { + const linked = join(root, "linked"); + git("worktree", "add", "-b", "side", linked); + cwd = linked; + } + git("config", "--worktree", "diff.context", "3"); + const configPath = resolve(cwd, git("rev-parse", "--git-path", "config.worktree")); + const configText = await readFile(configPath, "utf8"); + const subscription = await streams.subscribe( + { cwd, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: "HEAD" } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + const initialFiles = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(initialFiles[0].hunks[0].lines.filter((line) => line.type === "context")).toHaveLength( + 6, + ); + const attributesPath = resolve(cwd, git("rev-parse", "--git-path", "info/attributes")); + frames.length = 0; + await writeFile(attributesPath, "a.ts -diff\n"); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + frames.length = 0; + await writeFile(attributesPath, ""); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + const restoredFiles = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect( + restoredFiles[0].hunks[0].lines.filter((line) => line.type === "context"), + ).toHaveLength(6); + frames.length = 0; + const changedConfig = configText.replace("context = 3", "context = 0"); + expect(changedConfig).not.toBe(configText); + await writeFile(configPath, changedConfig); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const updatedFiles = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(updatedFiles[0].hunks[0].lines.filter((line) => line.type === "context")).toHaveLength( + 0, + ); + await subscription.release(); + } finally { + await streams.close(); + await rm(root, { recursive: true, force: true }); + } + }, + 30000, +); + +test("pinned comparisons refresh Git interpretation before replaying to a joining subscriber", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-pinned-interpretation-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + const joined: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.ts"), "export const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "a.ts"), "export const value = 2;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const compare = { + cwd, + base: { kind: "rev" as const, rev: base }, + head: { kind: "rev" as const, rev: git("rev-parse", "HEAD") }, + }; + const first = await streams.subscribe(compare, async (frame) => { + frames.push(frame); + }); + await first.start(); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "text" }], + }); + frames.length = 0; + await writeFile(join(cwd, ".git", "info", "attributes"), "a.ts -diff\n"); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const computations = streams.getMetrics().computations; + const second = await streams.subscribe(compare, async (frame) => { + joined.push(frame); + }); + await second.start(); + expect(joined.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(joined.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + expect(streams.getMetrics().computations).toBe(computations); + await second.release(); + await first.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30000); + +test.each(["pinned", "HEAD"] as const)( + "%s revision comparisons observe worktree attributes and replay their current result", + async (endpoint) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-worktree-attributes-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, changed) { + const watch = await observer.subscribe(root, () => changed(), { + ignore: [join(root, ".git")], + }); + return { repoRoot: root, unsubscribe: () => watch.unsubscribe() }; + }, + }); + const frames: DiffFrame[] = []; + const joined: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.ts"), "export const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "a.ts"), "export const value = 2;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const compare = { + cwd, + base: { kind: "rev" as const, rev: base }, + head: { + kind: "rev" as const, + rev: endpoint === "HEAD" ? "HEAD" : git("rev-parse", "HEAD"), + }, + }; + const first = await streams.subscribe(compare, async (frame) => { + frames.push(frame); + }); + await first.start(); + frames.length = 0; + await writeFile(join(cwd, ".gitattributes"), "a.ts -diff\n"); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const computations = streams.getMetrics().computations; + const second = await streams.subscribe(compare, async (frame) => { + joined.push(frame); + }); + await second.start(); + expect(joined.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(joined.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + expect(streams.getMetrics().computations).toBe(computations); + await second.release(); + await first.release(); + } finally { + await streams.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, + 30000, +); diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 95fe416c54..ccb451632a 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -165,7 +165,11 @@ test("classifies a removed file as deleted", async () => { }); await rm(target); - await expect.poll(() => events.find((event) => event.path === target)?.type).toBe("delete"); + // Native recursive delivery can be coalesced before the backend's deletion audit. + // Assert eventual classification without imposing Vitest's one-second default. + await expect + .poll(() => events.find((event) => event.path === target)?.type, { timeout: 10_000 }) + .toBe("delete"); await subscription.unsubscribe(); }); diff --git a/packages/server/src/server/file-observer/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts index 12ab1b18f3..8511e26bbf 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.test.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -60,6 +60,7 @@ test.each(["existing", "new"])( await Promise.all(removed.map((path) => rm(path))); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); for (const path of removed) notifications.emit("change", "change", path); + // The file-only event uses the eight-second optional audit debounce. await vi.advanceTimersByTimeAsync(8_000); vi.useRealTimers(); await expect @@ -309,79 +310,98 @@ test("a classification shed onto the scoped audit still recovers the file", asyn // re-link `directory` itself back into its parent -- walkSubtree only // descends through that link, so without the re-link a later ancestor // removal can no longer reach anything below the reconciled directory. -test("a recursive-scope reconcile re-links its directory so an ancestor removal still finds nested files", async () => { - const root = await mkdtemp(join(tmpdir(), "native-relink-")); - const paths = createObserverPaths(process.platform); - const deepDirectory = join(root, "a", "b", "c"); - const deepFile = join(deepDirectory, "deep.txt"); - await mkdir(deepDirectory, { recursive: true }); - await writeFile(deepFile, "content"); - const events: FileChange[] = []; - const notifications = new EventEmitter(); - const observer = createFileObserver(); - let active = true; - const backend = createNativeRecursiveBackend( - { - root, - metrics: observer.getDiagnostics(), - isActive: () => active, - isIgnored: () => false, - isPathInside: paths.isInside, - queueEvent: (type, path) => events.push({ type, path }), - fail: (error) => { - throw error; - }, - }, - paths, - (_root, listener) => { - notifications.on("change", listener); - return { - close: () => notifications.removeAllListeners(), - on: (event, onError) => notifications.on(event, onError), - }; - }, - ); - const settled = () => - expect - .poll( - () => { - const diagnostics = backend.getDiagnostics(); - return ( - diagnostics.pendingReconciliationWorkCount === 0 && !diagnostics.reconciliationInFlight - ); +test.each([ + { notification: "ancestor", deletedPath: ["a"] }, + { notification: "file", deletedPath: ["a", "b", "c", "deep.txt"] }, +])( + "a recursive-scope reconcile preserves nested deletions announced by $notification", + async ({ deletedPath }) => { + const root = await mkdtemp(join(tmpdir(), "native-relink-")); + const paths = createObserverPaths(process.platform); + const deepDirectory = join(root, "a", "b", "c"); + const deepFile = join(deepDirectory, "deep.txt"); + await mkdir(deepDirectory, { recursive: true }); + await writeFile(deepFile, "content"); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + const observer = createFileObserver(); + let active = true; + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; }, - { timeout: 10_000 }, - ) - .toBe(true); - try { - await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt. - - // A rename event landing on the already-known directory a/b produces a - // local scope on its parent "a" (rebuilding a's DirectoryEntry from a - // fresh shallow scan) and, once classify()'s real stat() confirms a/b is - // still a directory, a recursive scope on a/b itself -- the same "known - // directory changes in place" shape the directory-churn benchmark - // exercises via atomic replace. - notifications.emit("change", "rename", join(root, "a", "b")); - await settled(); - - // Remove the ancestor while the native event names only a deleted file. - // The scoped scan must report its missing directory as well as that file. - await rm(join(root, "a"), { recursive: true, force: true }); - notifications.emit("change", "rename", deepFile); - await settled(); - - expect(events.filter((event) => event.type === "delete").map((event) => event.path)).toContain( - deepFile, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, ); - expect(events).toContainEqual({ type: "delete", path: deepDirectory }); - } finally { - active = false; - await backend.close(); - await observer.close(); - await rm(root, { recursive: true, force: true }); - } -}, 15_000); + const settled = async () => { + // Classification uses real filesystem I/O and can request another audit. + // Drain it before advancing the debounce clock so no later classification + // leaves a fresh timer behind. + await expect + .poll(() => backend.getDiagnostics().pendingClassificationCount, { timeout: 10_000 }) + .toBe(0); + // The file-only event uses the eight-second optional audit debounce. + await vi.advanceTimersByTimeAsync(8_000); + await expect + .poll( + () => { + const diagnostics = backend.getDiagnostics(); + return ( + diagnostics.pendingReconciliationWorkCount === 0 && + !diagnostics.reconciliationInFlight + ); + }, + { timeout: 10_000 }, + ) + .toBe(true); + }; + try { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt. + + // A rename event landing on the already-known directory a/b produces a + // local scope on its parent "a" (rebuilding a's DirectoryEntry from a + // fresh shallow scan) and, once classify()'s real stat() confirms a/b is + // still a directory, a recursive scope on a/b itself -- the same "known + // directory changes in place" shape the directory-churn benchmark + // exercises via atomic replace. + notifications.emit("change", "rename", join(root, "a", "b")); + await settled(); + + // An ancestor notification must walk the re-linked subtree. A file-only + // notification must also recover the missing directory from its scoped scan. + await rm(join(root, "a"), { recursive: true, force: true }); + notifications.emit("change", "rename", join(root, ...deletedPath)); + await settled(); + + expect( + events.filter((event) => event.type === "delete").map((event) => event.path), + ).toContain(deepFile); + expect(events).toContainEqual({ type: "delete", path: deepDirectory }); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } + }, + 15_000, +); test("a full native inventory audit reports a removed directory", async () => { const root = await mkdtemp(join(tmpdir(), "native-full-audit-")); diff --git a/packages/server/src/server/git-filename-diffs.e2e.test.ts b/packages/server/src/server/git-filename-diffs.e2e.test.ts index c32ee040b8..4b8bcb1566 100644 --- a/packages/server/src/server/git-filename-diffs.e2e.test.ts +++ b/packages/server/src/server/git-filename-diffs.e2e.test.ts @@ -29,6 +29,8 @@ test("a rename-only commit returns zero added or deleted lines through the clien expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({ file: { path: "new.txt", + oldPath: "old.txt", + status: "ok", isNew: false, isDeleted: false, additions: 0, @@ -98,3 +100,144 @@ test.skipIf(process.platform === "win32")( } }, ); + +test("streamed Changes preserves BOM-prefixed paths and highlighted content", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stream-bom-diff-")); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + execFileSync("git", ["init", "-b", "main"], { cwd }); + writeFileSync(join(cwd, "\uFEFFbom.ts"), "\uFEFFexport const value = 1;\n"); + writeFileSync(join(cwd, "other.txt"), "other edit\n"); + await client.connect(); + const states: import("@getpaseo/client").DiffState[] = []; + const observation = client.observeDiff({ + cwd, + base: { kind: "empty" }, + head: { kind: "worktree" }, + }); + const unsubscribe = observation.subscribe({ + snapshot: (state) => states.push(state), + update: () => {}, + }); + try { + await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready"); + const readyState = states.at(-1); + if (readyState?.phase !== "ready") throw new Error("Expected ready diff"); + expect(readyState.files.map((file) => file.path)).toContain("other.txt"); + const bomFile = readyState.files.find((file) => file.path === "\uFEFFbom.ts"); + const addedLine = bomFile?.hunks + .flatMap((hunk) => hunk.lines) + .find((line) => line.type === "add"); + expect(addedLine?.content).toBe("\uFEFFexport const value = 1;"); + expect(addedLine?.tokens?.map((token) => token.text).join("")).toBe(addedLine?.content); + expect(states.some((state) => state.phase === "error")).toBe(false); + } finally { + unsubscribe(); + await observation.release(); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test("checkout refresh from a subdirectory updates a pinned streamed comparison", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stream-subdirectory-refresh-")); + const { mkdirSync } = await import("node:fs"); + const workspace = join(cwd, "workspace"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + mkdirSync(workspace); + writeFileSync(join(cwd, "file.txt"), "committed content\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const head = git("rev-parse", "HEAD"); + await client.connect(); + const states: import("@getpaseo/client").DiffState[] = []; + const observation = client.observeDiff({ + cwd: workspace, + base: { kind: "empty" }, + head: { kind: "rev", rev: head }, + }); + const unsubscribe = observation.subscribe({ + snapshot: (state) => states.push(state), + update: () => {}, + }); + try { + await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready"); + const initial = states.at(-1); + if (initial?.phase !== "ready") throw new Error("Expected initial diff"); + expect(initial.files[0]).toMatchObject({ path: "file.txt", status: "ok" }); + expect(await client.checkoutRefresh(workspace)).toMatchObject({ success: true, error: null }); + await expect + .poll(() => states.at(-1)?.revision, { timeout: 15000 }) + .toBeGreaterThan(initial.revision); + expect(states.at(-1)).toMatchObject({ + phase: "ready", + files: [{ path: "file.txt", status: "ok" }], + }); + writeFileSync(join(cwd, ".git", "info", "attributes"), "file.txt -diff\n"); + await expect + .poll(() => states.at(-1), { timeout: 15000 }) + .toMatchObject({ + phase: "ready", + files: [{ path: "file.txt", status: "binary", hunks: [] }], + }); + } finally { + unsubscribe(); + await observation.release(); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test("refreshing a non-Git workspace succeeds while a Git comparison is open", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stream-refresh-git-")); + const plain = mkdtempSync(join(tmpdir(), "stream-refresh-plain-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "file.txt"), "committed content\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await client.connect(); + const states: import("@getpaseo/client").DiffState[] = []; + const observation = client.observeDiff({ + cwd, + base: { kind: "empty" }, + head: { kind: "rev", rev: git("rev-parse", "HEAD") }, + }); + const unsubscribe = observation.subscribe({ + snapshot: (state) => states.push(state), + update: () => {}, + }); + try { + await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready"); + const revision = states.at(-1)?.revision; + expect(await client.checkoutRefresh(plain)).toMatchObject({ success: true, error: null }); + expect(states.at(-1)).toMatchObject({ phase: "ready", revision }); + } finally { + unsubscribe(); + await observation.release(); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + rmSync(plain, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); diff --git a/packages/server/src/server/git-metadata-event-rules.test.ts b/packages/server/src/server/git-metadata-event-rules.test.ts index 4456573624..43edc861d5 100644 --- a/packages/server/src/server/git-metadata-event-rules.test.ts +++ b/packages/server/src/server/git-metadata-event-rules.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { classifyGitMetadataPath, + affectsGitDiffInterpretation, GIT_METADATA_EVENT_RULES, getPrunedGitMetadataPaths, } from "./git-metadata-event-rules.js"; @@ -35,14 +36,28 @@ describe("Git metadata event rules", () => { ["common", "packed-refs", { kind: "all", refreshBase: true }], ["common", "reftable/tables.list", { kind: "all", refreshBase: true }], ["common", "config", { kind: "all", refreshBase: true }], + ["common", "info/attributes", { kind: "all", refreshBase: false }], ["common", "worktrees/new/index", { kind: "ignore" }], ["common", "hooks/pre-commit", { kind: "ignore" }], ["common", "logs/refs/heads/main", { kind: "ignore" }], ["common", "objects/ab/cdef", { kind: "ignore" }], - ["common", "refs/tags/v1", { kind: "ignore" }], + ["common", "refs/tags/v1", { kind: "all", refreshBase: true }], ["common", "FETCH_HEAD", { kind: "ignore" }], ["common", "packed-refs.lock", { kind: "ignore" }], ] as const)("classifies %s metadata path %s", (scope, path, expected) => { expect(classifyGitMetadataPath(scope, path)).toEqual(expected); }); }); + +test.each([ + ["worktree", "config.worktree", true], + ["common", "config.worktree", false], + ["common", "config", true], + ["common", "info/attributes", true], + ["worktree", "info/attributes", false], + ["common", "refs/heads/main", false], + ["worktree", "index", false], + ["common", "config.lock", false], +] as const)("diff interpretation dependency %s/%s is %s", (scope, path, expected) => { + expect(affectsGitDiffInterpretation(scope, path)).toBe(expected); +}); diff --git a/packages/server/src/server/git-metadata-event-rules.ts b/packages/server/src/server/git-metadata-event-rules.ts index ff82747ba0..2557639f16 100644 --- a/packages/server/src/server/git-metadata-event-rules.ts +++ b/packages/server/src/server/git-metadata-event-rules.ts @@ -5,6 +5,7 @@ type GitMetadataEventRule = { scope: GitMetadataScope | "both"; path: string; prune?: true; + diffInput?: true; } & ( | { match: "exact" | "prefix" | "suffix"; route: "ignore" } | { match: "exact" | "prefix" | "suffix"; route: "owner" | "all"; refreshBase: boolean } @@ -36,6 +37,7 @@ export const GIT_METADATA_EVENT_RULES = [ }, { id: "worktree-config", + diffInput: true, scope: "worktree", match: "exact", path: "config.worktree", @@ -73,6 +75,14 @@ export const GIT_METADATA_EVENT_RULES = [ path: "refs/heads/", route: "local-ref", }, + { + id: "tag", + scope: "common", + match: "prefix", + path: "refs/tags/", + route: "all", + refreshBase: true, + }, { id: "remote-branch", scope: "common", @@ -98,12 +108,22 @@ export const GIT_METADATA_EVENT_RULES = [ }, { id: "shared-config", + diffInput: true, scope: "common", match: "exact", path: "config", route: "all", refreshBase: true, }, + { + id: "shared-attributes", + diffInput: true, + scope: "common", + match: "exact", + path: "info/attributes", + route: "all", + refreshBase: false, + }, { id: "worktree-administration", scope: "common", @@ -150,11 +170,7 @@ export function classifyGitMetadataPath( inputPath: string, ): GitMetadataEffect { const path = inputPath.replaceAll("\\", "/"); - const rule = GIT_METADATA_EVENT_RULES.find( - (candidate) => - (candidate.scope === "both" || candidate.scope === scope) && - matchesGitMetadataPath(candidate.match, candidate.path, path), - ); + const rule = findGitMetadataRule(scope, path); if (!rule || rule.route === "ignore") return { kind: "ignore" }; if (rule.route === "owner" || rule.route === "all") { return { kind: rule.route, refreshBase: rule.refreshBase }; @@ -166,6 +182,19 @@ export function classifyGitMetadataPath( }; } +export function affectsGitDiffInterpretation(scope: GitMetadataScope, inputPath: string): boolean { + const rule = findGitMetadataRule(scope, inputPath.replaceAll("\\", "/")); + return rule !== undefined && "diffInput" in rule && rule.diffInput; +} + +function findGitMetadataRule(scope: GitMetadataScope, path: string) { + return GIT_METADATA_EVENT_RULES.find( + (candidate) => + (candidate.scope === "both" || candidate.scope === scope) && + matchesGitMetadataPath(candidate.match, candidate.path, path), + ); +} + function matchesGitMetadataPath( match: GitMetadataEventRule["match"], pattern: string, diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts index e41ccd6df5..c210118f1f 100644 --- a/packages/server/src/server/owned-subscriptions.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -1912,3 +1912,115 @@ test("public project subscriptions request updates and release their producer de await daemon.close(); } }); + +test.each([ + { closed: false, batch: false }, + { closed: true, batch: false }, + { closed: false, batch: true }, + { closed: true, batch: true }, +])( + "clearing workspace attention reaches independent subscribers (closed=$closed, batch=$batch)", + async ({ closed, batch }) => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const clients = Array.from( + { length: 4 }, + () => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }), + ); + const [actor, observer, unreadObserver, quiet] = clients; + const updates: SessionOutboundMessage[] = []; + const unreadUpdates: SessionOutboundMessage[] = []; + const quietUpdates: SessionOutboundMessage[] = []; + const responses: SessionOutboundMessage[] = []; + try { + await Promise.all(clients.map((client) => client.connect())); + const agentIds: string[] = []; + for (let index = 0; index < (batch ? 2 : 1); index += 1) { + const cwd = path.join(daemon.staticDir, `workspace-${index}`); + await mkdir(cwd); + const agent = await actor.createAgent({ provider: "codex", cwd }); + await actor.sendMessage(agent.id, "Reply with OK."); + await actor.waitForFinish(agent.id, 5000); + await daemon.daemon.agentManager.clearAgentAttention(agent.id); + if (closed) await daemon.daemon.agentManager.closeAgent(agent.id); + agentIds.push(agent.id); + } + const snapshot = await observer.fetchAgents({ + filter: { includeArchived: true }, + subscribe: {}, + }); + const workspaceIds = agentIds.map( + (id) => snapshot.entries.find((entry) => entry.agent.id === id)!.agent.workspaceId!, + ); + expect(new Set(workspaceIds).size).toBe(agentIds.length); + await unreadObserver.fetchAgents({ filter: { requiresAttention: true }, subscribe: {} }); + actor.subscribeRawMessages((message) => responses.push(message)); + observer.subscribeRawMessages((message) => updates.push(message)); + unreadObserver.subscribeRawMessages((message) => unreadUpdates.push(message)); + quiet.subscribeRawMessages((message) => quietUpdates.push(message)); + const attention = (id: string) => + updates + .flatMap((message) => + message.type === "agent_update" && + message.payload.kind === "upsert" && + message.payload.agent.id === id + ? [message.payload.agent.requiresAttention] + : [], + ) + .at(-1); + for (const [index, id] of agentIds.entries()) { + await actor.markWorkspaceUnread(workspaceIds[index]); + await expect.poll(() => attention(id)).toBe(true); + await expect + .poll(() => + unreadUpdates.some( + (message) => + message.type === "agent_update" && + message.payload.kind === "upsert" && + message.payload.agent.id === id, + ), + ) + .toBe(true); + } + updates.length = unreadUpdates.length = quietUpdates.length = 0; + await actor.clearWorkspaceAttention(batch ? workspaceIds : workspaceIds[0]); + const response = responses.findLast( + (message) => message.type === "workspace.clear_attention.response", + ); + expect(response?.payload).toEqual({ + requestId: expect.any(String), + workspaceId: batch ? workspaceIds : workspaceIds[0], + clearedAgentIds: agentIds, + results: workspaceIds.map((workspaceId, index) => ({ + workspaceId, + clearedAgentIds: [agentIds[index]], + success: true, + error: null, + })), + success: true, + error: null, + }); + const fresh = await actor.fetchAgents(); + for (const id of agentIds) { + expect(fresh.entries.find((entry) => entry.agent.id === id)?.agent.requiresAttention).toBe( + false, + ); + expect(Boolean(daemon.daemon.agentManager.getAgent(id))).toBe(!closed); + await expect.poll(() => attention(id)).toBe(false); + await expect + .poll(() => + unreadUpdates.some( + (message) => + message.type === "agent_update" && + message.payload.kind === "remove" && + message.payload.agentId === id, + ), + ) + .toBe(true); + } + expect(quietUpdates.filter((message) => message.type === "agent_update")).toEqual([]); + } finally { + await Promise.all(clients.map((client) => client.close())); + await daemon.close(); + } + }, +); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cd5ee105bd..9221aa5e0b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import { DiffSession } from "./diff/index.js"; import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js"; @@ -856,6 +857,7 @@ export class Session { private readonly workspaceDirectory: WorkspaceDirectory; private readonly voiceSessions: VoiceSessions; private readonly checkoutSession: CheckoutSession; + private readonly diffSession: DiffSession; private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; private readonly usageSession: UsageSession; @@ -1020,6 +1022,7 @@ export class Session { await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace); }, }); + this.diffSession = new DiffSession(checkoutDiffManager.streams, this.sessionLogger); this.checkoutSession = new CheckoutSession({ host: { emit: (msg) => this.emit(msg), @@ -3191,6 +3194,10 @@ export class Session { // eslint-disable-next-line complexity private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { + case "diff.subscribe.request": + return this.diffSession.subscribe(msg, this.delivery, (reply) => this.emit(reply)); + case "diff.unsubscribe.request": + return this.delivery.release(msg.subscriptionId); case "checkout_status_request": return this.checkoutSession.handleStatusRequest(msg); case "checkout.commits.list.request": @@ -3504,7 +3511,9 @@ export class Session { await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source); return; } - this.terminalController.handleBinaryFrame(binaryFrame.frame, source); + if (binaryFrame.kind === "terminal") { + this.terminalController.handleBinaryFrame(binaryFrame.frame, source); + } } private async handleRestartServerRequest( @@ -8164,24 +8173,7 @@ export class Session { ) { continue; } - const nextRecord: StoredAgentRecord = { - ...record, - updatedAt: new Date().toISOString(), - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - }; - await this.agentStorage.upsert(nextRecord); - const agent = this.buildStoredAgentPayload(nextRecord); - const project = await this.buildProjectPlacementForWorkspace(workspace); - this.emit({ - type: "agent_update", - payload: { - kind: "upsert", - agent, - project, - }, - }); + await this.agentManager.clearAgentAttention(agentId); clearedAgentIds.push(agentId); } diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 890d9b8025..b3ededb352 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1847,81 +1847,6 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" }); }); -test("workspace clear attention clears stored-only agents and responds", async () => { - const emitted: SessionOutboundMessage[] = []; - const workspace = createPersistedWorkspaceRecord({ - workspaceId: REPO_CWD, - projectId: REPO_CWD, - cwd: REPO_CWD, - kind: "directory", - displayName: "repo", - createdAt: "2026-03-30T15:00:00.000Z", - updatedAt: "2026-03-30T15:00:00.000Z", - }); - const project = createPersistedProjectRecord({ - projectId: REPO_CWD, - rootPath: REPO_CWD, - kind: "non_git", - displayName: "repo", - createdAt: "2026-03-30T15:00:00.000Z", - updatedAt: "2026-03-30T15:00:00.000Z", - }); - let storedRecord = makeStoredAgent({ - id: "stored-agent-1", - cwd: REPO_CWD, - updatedAt: "2026-03-30T15:00:00.000Z", - requiresAttention: true, - attentionReason: "finished", - }); - const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); - - session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [ - workspace, - ]; - session.workspaceRegistry.get = async (id: string) => - id === workspace.workspaceId ? workspace : null; - session.projectRegistry.list = async () => [project]; - session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); - session.agentStorage.get = async (agentId: string) => - agentId === storedRecord.id ? storedRecord : null; - session.agentStorage.upsert = async (record: unknown) => { - storedRecord = record as StoredAgentRecord; - }; - session.listAgentPayloads = async () => [ - makeAgent({ - id: storedRecord.id, - cwd: storedRecord.cwd, - workspaceId: workspace.workspaceId, - status: "closed", - updatedAt: storedRecord.updatedAt, - requiresAttention: true, - attentionReason: "finished", - }), - ]; - - await session.handleMessage({ - type: "workspace.clear_attention.request", - workspaceId: workspace.workspaceId, - requestId: "req-1", - }); - - expect(storedRecord.requiresAttention).toBe(false); - expect(storedRecord.attentionReason).toBeNull(); - expect(storedRecord.attentionTimestamp).toBeNull(); - expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({ - requestId: "req-1", - workspaceId: workspace.workspaceId, - clearedAgentIds: [storedRecord.id], - success: true, - error: null, - }); - const agentUpdate = findByType(emitted, "agent_update"); - expect(agentUpdate.payload.kind).toBe("upsert"); - if (agentUpdate.payload.kind === "upsert") { - expect(agentUpdate.payload.agent.requiresAttention).toBe(false); - } -}); - test("workspace clear attention responds with an error instead of timing out", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); @@ -2201,110 +2126,6 @@ test("workspace mark unread rejects workspaces without a read root agent", async }); }); -test("workspace clear attention can clear multiple workspaces in one request", async () => { - const emitted: SessionOutboundMessage[] = []; - const workspaces = [ - createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-a", - projectId: "/tmp/repo-a", - cwd: "/tmp/repo-a", - kind: "directory", - displayName: "repo-a", - createdAt: "2026-03-30T15:00:00.000Z", - updatedAt: "2026-03-30T15:00:00.000Z", - }), - createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-b", - projectId: "/tmp/repo-b", - cwd: "/tmp/repo-b", - kind: "directory", - displayName: "repo-b", - createdAt: "2026-03-30T15:00:00.000Z", - updatedAt: "2026-03-30T15:00:00.000Z", - }), - ]; - const projects = workspaces.map((workspace) => - createPersistedProjectRecord({ - projectId: workspace.projectId, - rootPath: workspace.cwd, - kind: "non_git", - displayName: workspace.displayName, - createdAt: "2026-03-30T15:00:00.000Z", - updatedAt: "2026-03-30T15:00:00.000Z", - }), - ); - const storedRecords = new Map( - workspaces.map((workspace, index) => [ - `stored-agent-${index + 1}`, - makeStoredAgent({ - id: `stored-agent-${index + 1}`, - cwd: workspace.cwd, - updatedAt: "2026-03-30T15:00:00.000Z", - requiresAttention: true, - attentionReason: "finished", - }), - ]), - ); - const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); - - session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => workspaces; - session.workspaceRegistry.get = async (id: string) => - workspaces.find((workspace) => workspace.workspaceId === id) ?? null; - session.projectRegistry.list = async () => projects; - session.projectRegistry.get = async (id: string) => - projects.find((project) => project.projectId === id) ?? null; - session.agentStorage.get = async (agentId: string) => storedRecords.get(agentId) ?? null; - session.agentStorage.upsert = async (record: unknown) => { - const storedRecord = record as StoredAgentRecord; - storedRecords.set(storedRecord.id, storedRecord); - }; - session.listAgentPayloads = async () => - Array.from(storedRecords.values()).map((record) => { - const owner = workspaces.find((workspace) => workspace.cwd === record.cwd); - return makeAgent({ - id: record.id, - cwd: record.cwd, - ...(owner ? { workspaceId: owner.workspaceId } : {}), - status: "closed", - updatedAt: record.updatedAt, - requiresAttention: record.requiresAttention, - attentionReason: record.attentionReason, - }); - }); - - await session.handleMessage({ - type: "workspace.clear_attention.request", - workspaceId: workspaces.map((workspace) => workspace.workspaceId), - requestId: "req-1", - }); - - expect(Array.from(storedRecords.values()).map((record) => record.requiresAttention)).toEqual([ - false, - false, - ]); - expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({ - requestId: "req-1", - workspaceId: workspaces.map((workspace) => workspace.workspaceId), - clearedAgentIds: ["stored-agent-1", "stored-agent-2"], - results: [ - { - workspaceId: workspaces[0].workspaceId, - clearedAgentIds: ["stored-agent-1"], - success: true, - error: null, - }, - { - workspaceId: workspaces[1].workspaceId, - clearedAgentIds: ["stored-agent-2"], - success: true, - error: null, - }, - ], - success: true, - error: null, - }); -}); - test("close_items_request archives agents and kills terminals in one batch", async () => { const emitted: SessionOutboundMessage[] = []; const archivedAt = "2026-04-01T00:00:00.000Z"; diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index aaa79e3da1..61c9328e48 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -50,6 +50,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { const subscriptions: FakeDiffSubscription[] = []; const refreshedCwds: string[] = []; const subscriber: CheckoutDiffSubscriber = { + readCommitFile: async () => null, read: async (params) => ({ ...initial, cwd: params.cwd }), subscribe: async (params, listener) => { let isSubscribed = true; @@ -77,7 +78,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { unsubscribe, }; }, - scheduleRefreshForCwd: (cwd) => { + scheduleRefreshForCwd: async (cwd) => { refreshedCwds.push(cwd); }, }; diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 4deb28a56b..1611356bc8 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -51,7 +51,6 @@ import { pullCurrentBranch, pushCurrentBranch, listCheckoutCommits, - getCommitFileDiff, } from "../../../utils/checkout-git.js"; import { runGitCommand } from "../../../utils/run-git-command.js"; import { expandTilde } from "../../../utils/path.js"; @@ -109,6 +108,7 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub * real CheckoutDiffManager satisfies this structurally; tests supply a fake. */ export interface CheckoutDiffSubscriber { + readCommitFile: import("../../diff/index.js").DiffSubscriptions["readCommitFile"]; read( params: Omit, ): Promise; @@ -116,7 +116,7 @@ export interface CheckoutDiffSubscriber { params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): Promise; - scheduleRefreshForCwd(cwd: string): void; + scheduleRefreshForCwd(cwd: string): Promise; } export interface CheckoutSessionOptions { @@ -301,7 +301,12 @@ export class CheckoutSession { if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) { throw new Error(`Invalid path: ${path}`); } - const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path, signal }); + const file = await this.checkoutDiffManager.readCommitFile({ + cwd: expandTilde(cwd), + sha, + path, + signal, + }); this.host.emit({ type: "checkout.commits.file_diff.response", payload: { cwd, sha, path, file, error: null, requestId }, @@ -494,7 +499,7 @@ export class CheckoutSession { includeForge: true, reason: "manual-refresh", }); - this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd); + await this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd); this.host.emit({ type: "checkout.refresh.response", payload: { @@ -548,8 +553,8 @@ export class CheckoutSession { * Notify the live diff subscriptions that the working tree at `cwd` changed. * Called by the command handlers below after they mutate the repository. */ - private scheduleDiffRefresh(cwd: string): void { - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + private scheduleDiffRefresh(cwd: string): Promise { + return this.checkoutDiffManager.scheduleRefreshForCwd(cwd); } // --------------------------------------------------------------------------- @@ -563,7 +568,7 @@ export class CheckoutSession { try { const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. @@ -615,7 +620,7 @@ export class CheckoutSession { try { const result = await this.host.renameCurrentBranch(cwd, branch); await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); // Branch is a git fact derived per-descriptor from each workspace's own @@ -657,7 +662,7 @@ export class CheckoutSession { try { await discardChanges(cwd, paths); await this.gitMutation.notifyGitMutation(cwd, "discard-changes"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout.discard_changes.response", payload: { cwd, success: true, error: null, requestId }, @@ -684,7 +689,7 @@ export class CheckoutSession { timeout: 120_000, }); await this.gitMutation.notifyGitMutation(cwd, "stash-push"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "stash_save_response", payload: { cwd, success: true, error: null, requestId }, @@ -707,7 +712,7 @@ export class CheckoutSession { timeout: 120_000, }); await this.gitMutation.notifyGitMutation(cwd, "stash-pop"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "stash_pop_response", payload: { cwd, success: true, error: null, requestId }, @@ -759,7 +764,7 @@ export class CheckoutSession { addAll: msg.addAll ?? true, }); await this.gitMutation.notifyGitMutation(cwd, "commit-changes"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_commit_response", @@ -820,7 +825,7 @@ export class CheckoutSession { this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }), ...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []), ]); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_merge_response", @@ -866,7 +871,7 @@ export class CheckoutSession { { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, ); await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_merge_from_base_response", @@ -898,7 +903,7 @@ export class CheckoutSession { try { await pullCurrentBranch(cwd); await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_pull_response", diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index a8decb3f30..3c942d0c9e 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,3 +1,4 @@ +import { DiffSubscriptions } from "./diff/index.js"; import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { @@ -105,6 +106,13 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ + checkoutDiffManager: createStub({ + streams: new DiffSubscriptions({ + async requestWorkingTreeWatch() { + throw new Error("Unexpected diff observation"); + }, + }), + }), workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index e3a0460322..6404ce2381 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -70,11 +70,6 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri function extractPathFromDiffHeader(lines: string[]): string { const firstLine = lines[0] ?? ""; - const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/); - if (prefixedPathMatch) { - return prefixedPathMatch[2]; - } - const quotedPathMatch = firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/); if (quotedPathMatch) { @@ -86,10 +81,21 @@ function extractPathFromDiffHeader(lines: string[]): string { return newPath; } - const metadataPath = - extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- "); - if (metadataPath) { - return metadataPath; + // The ---/+++ records are unambiguous even when a path contains " b/". + const oldMetadata = extractPathFromMetadata(lines, "--- "); + const newMetadata = extractPathFromMetadata(lines, "+++ "); + const metadata = newMetadata ?? oldMetadata; + if (metadata) { + const prefixed = + (oldMetadata !== null && + newMetadata !== null && + usesDiffPathPrefixes(oldMetadata, newMetadata)) || + firstLine === `a/${metadata.slice(2)} b/${metadata.slice(2)}`; + return prefixed ? metadata.slice(2) : metadata; + } + const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/); + if (prefixedPathMatch) { + return prefixedPathMatch[2]; } const pathMatch = firstLine.match(/^(\S+)\s+(\S+)$/); @@ -462,7 +468,8 @@ function applyTokensToHunks( newLineNum++; } - return tokens ? { ...line, tokens } : line; + const matchingTokens = tokensForPatchLine(tokens, line.content); + return matchingTokens ? { ...line, tokens: matchingTokens } : line; }); return { ...hunk, lines: highlightedLines }; @@ -471,6 +478,25 @@ function applyTokensToHunks( return { ...file, hunks: highlightedHunks }; } +function tokensForPatchLine( + tokens: HighlightToken[] | undefined, + content: string, +): HighlightToken[] | undefined { + if (!tokens) return undefined; + const source = tokens.map((token) => token.text).join(""); + if (source === content) return tokens; + // Git can normalize a CRLF worktree while source highlighting retains its CR. + if (source !== `${content}\r`) return undefined; + const aligned: HighlightToken[] = []; + let remaining = content.length; + for (const token of tokens) { + const text = token.text.slice(0, remaining); + if (text) aligned.push({ text, style: token.style }); + remaining -= text.length; + } + return aligned; +} + /** * Parse and highlight a complete diff, using actual file content when available. */ diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 37e5e76b45..e671214874 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1231,7 +1231,7 @@ export class VoiceAssistantWebSocketServer { await Promise.all(cleanupPromises); this.providerSnapshotManager.destroy(); - this.checkoutDiffManager.dispose(); + await this.checkoutDiffManager.dispose(); await this.workspaceGitService.dispose(); this.pendingConnections.clear(); this.sessions.clear(); @@ -2031,6 +2031,7 @@ export class VoiceAssistantWebSocketServer { ownedSubscriptions: true, providerOptionControls: true, entityMetadata: true, + diffSubscription: true, binaryChunkingV1: true, agentRequestReceipts: true, workspaceRequestReceipts: true, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index a0de895810..a4f6fbe9dd 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -258,7 +258,7 @@ function createSessionForWireCompatTest(options?: { directorySync: options?.directorySync, scheduleService: {} as SessionOptions["scheduleService"], checkoutDiffManager: { - scheduleRefreshForCwd() {}, + async scheduleRefreshForCwd() {}, onWorkspaceStateMayHaveChanged() {}, } as unknown as SessionOptions["checkoutDiffManager"], github: { diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 17e3a4a2af..0a5152e5d4 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -11,7 +11,6 @@ import { type CheckoutSnapshotFacts, type CheckoutStatusGit, } from "../utils/checkout-git.js"; -import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { createFileObserver } from "./file-observer/index.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js"; @@ -250,24 +249,15 @@ test("recursive observation updates tracked state and prunes ignored storms", as }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: path.join(tempDir, "paseo-home"), - workspaceGitService: service, - }); + const summaryListener = vi.fn(); const diffListener = vi.fn(); const summarySubscription = service.registerWorkspace({ cwd: repoDir }, summaryListener); - const diffSubscription = await diffManager.subscribe( - { cwd: repoDir, compare: { mode: "uncommitted" } }, - diffListener, - ); + const diffSubscription = await service.requestWorkingTreeWatch(repoDir, diffListener); cleanup.push(async () => { diffSubscription.unsubscribe(); summarySubscription.unsubscribe(); - diffManager.dispose(); await service.dispose(); expect(activeWatcherCount).toBe(0); rmSync(tempDir, { recursive: true, force: true }); @@ -312,6 +302,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as getCheckoutShortstat.mockClear(); getCheckoutWorktreeState.mockClear(); getCheckoutDiff.mockClear(); + diffListener.mockClear(); runGitCommand.mockClear(); deliveredEvents.length = 0; @@ -344,18 +335,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: repoDir, - files: [ - { - path: "src/tracked.txt", - additions: 2, - deletions: 0, - status: "modified", - }, - ], - error: null, - }); + expect(diffListener).toHaveBeenCalled(); expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); }, { timeout: 5_000 }, @@ -371,11 +351,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as }), }), ); - expect(diffListener).toHaveBeenLastCalledWith( - expect.objectContaining({ - files: [expect.objectContaining({ additions: 3 })], - }), - ); + expect(diffListener).toHaveBeenCalled(); expect(service.getMetrics()).toMatchObject({ workspaceRefreshInFlightCount: 0, workspaceRefreshQueuedCount: 0, @@ -409,18 +385,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: repoDir, - files: [ - { - path: "build/tracked.txt", - additions: 2, - deletions: 0, - status: "modified", - }, - ], - error: null, - }); + expect(diffListener).toHaveBeenCalled(); expect(service.getMetrics()).toMatchObject({ workspaceRefreshInFlightCount: 0, workspaceRefreshQueuedCount: 0, @@ -434,6 +399,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as getCheckoutShortstat.mockClear(); getCheckoutWorktreeState.mockClear(); getCheckoutDiff.mockClear(); + diffListener.mockClear(); runGitCommand.mockClear(); summaryListener.mockClear(); diffListener.mockClear(); @@ -449,18 +415,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: repoDir, - files: [ - { - path: "build/tracked.txt", - additions: 4, - deletions: 0, - status: "modified", - }, - ], - error: null, - }); + expect(diffListener).toHaveBeenCalled(); expect(service.getMetrics()).toMatchObject({ workspaceRefreshInFlightCount: 0, workspaceRefreshQueuedCount: 0, @@ -474,6 +429,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as getCheckoutShortstat.mockClear(); getCheckoutWorktreeState.mockClear(); getCheckoutDiff.mockClear(); + diffListener.mockClear(); runGitCommand.mockClear(); for (let index = 0; index < 100; index += 1) { diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 86a49b55ac..f866aa8dc6 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -4,7 +4,6 @@ import { pathToFileURL } from "node:url"; import type pino from "pino"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; -import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { FileObserver } from "./file-observer/index.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; @@ -428,41 +427,30 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("a tracked edit refreshes summary and active uncommitted diff without structural or forge work", async () => { + test("a tracked edit refreshes summary and notifies worktree consumers without structural or forge work", async () => { const watcher = createWatcherHarness(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd)); let diffStat = { additions: 1, deletions: 0 }; - let diffFile = { path: "tracked.txt", additions: 1, deletions: 0, status: "modified" }; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty: true }), ); const getCheckoutShortstat = vi.fn(async () => diffStat); const getPullRequestStatus = vi.fn(); - const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [diffFile] })); const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus, getCheckoutShortstat, getPullRequestStatus, - getCheckoutDiff, }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: "/tmp/paseo-home", - workspaceGitService: service, - }); + const summaryListener = vi.fn(); const diffListener = vi.fn(); await service.getSnapshot(REPO_CWD); const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener); - const diffSubscription = await diffManager.subscribe( - { cwd: REPO_CWD, compare: { mode: "uncommitted" } }, - diffListener, - ); + const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener); await vi.waitFor(() => { expect(watcher.subscribe).toHaveBeenCalledWith( REPO_CWD, @@ -475,7 +463,6 @@ describe("WorkspaceGitService checkout observation", () => { }); diffStat = { additions: 4, deletions: 2 }; - diffFile = { path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }; watcher.records[0]?.callback(null, [ { path: path.join(REPO_CWD, "tracked.txt"), type: "update" }, ]); @@ -489,11 +476,7 @@ describe("WorkspaceGitService checkout observation", () => { }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: REPO_CWD, - files: [{ path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }], - error: null, - }); + expect(diffListener).toHaveBeenCalledTimes(1); }); await flushPromises(); @@ -502,7 +485,6 @@ describe("WorkspaceGitService checkout observation", () => { diffSubscription.unsubscribe(); summarySubscription.unsubscribe(); - diffManager.dispose(); service.dispose(); }); @@ -1245,7 +1227,7 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("metadata-only changes refresh worktree summary and active uncommitted diff", async () => { + test("metadata-only changes refresh worktree summary and invalidate the uncommitted projection", async () => { const watcher = createWatcherHarness(); let isDirty = true; let diffStat = { additions: 3, deletions: 1 }; @@ -1260,19 +1242,10 @@ describe("WorkspaceGitService checkout observation", () => { }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: "/tmp/paseo-home", - workspaceGitService: service, - }); + const summaryListener = vi.fn(); - const diffListener = vi.fn(); const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener); - const diffSubscription = await diffManager.subscribe( - { cwd: REPO_CWD, compare: { mode: "uncommitted" } }, - diffListener, - ); + await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); await vi.waitFor(() => { expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); @@ -1295,16 +1268,13 @@ describe("WorkspaceGitService checkout observation", () => { }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: REPO_CWD, - files: [], - error: null, - }); }); - diffSubscription.unsubscribe(); + expect(await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).toMatchObject({ + structured: [], + }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); summarySubscription.unsubscribe(); - diffManager.dispose(); service.dispose(); }); @@ -1318,20 +1288,14 @@ describe("WorkspaceGitService checkout observation", () => { const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: "/tmp/paseo-home", - workspaceGitService: service, - }); - const diffSubscription = await diffManager.subscribe( - { cwd: REPO_CWD, compare: { mode: "base", baseRef: "main" } }, - vi.fn(), - ); + + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {}); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); await vi.waitFor(() => { expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); expect(getCheckoutDiff).toHaveBeenCalledTimes(1); watcher.records @@ -1339,6 +1303,7 @@ describe("WorkspaceGitService checkout observation", () => { ?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]); await vi.advanceTimersByTimeAsync(1_000); await flushPromises(); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); expect(getCheckoutDiff).toHaveBeenCalledTimes(1); watcher.records @@ -1346,12 +1311,10 @@ describe("WorkspaceGitService checkout observation", () => { ?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]); await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(150); - await vi.waitFor(() => { - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - }); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - diffSubscription.unsubscribe(); - diffManager.dispose(); + subscription.unsubscribe(); service.dispose(); }); @@ -2773,39 +2736,23 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("a working-tree watcher event refreshes summary and diff subscribers", async () => { + test("a working-tree watcher event refreshes summary and notifies worktree consumers", async () => { const watcher = createWatcherHarness(); let isDirty = false; let diffStat = { additions: 0, deletions: 0 }; - let diffFiles: Array<{ - path: string; - additions: number; - deletions: number; - status: string; - }> = []; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty })); const getCheckoutShortstat = vi.fn(async () => diffStat); - const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: diffFiles })); const service = createService(watcher, { getCheckoutStatus, getCheckoutShortstat, - getCheckoutDiff, }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: "/tmp/paseo-home", - workspaceGitService: service, - }); + const listener = vi.fn(); const diffListener = vi.fn(); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); - const diffSubscription = await diffManager.subscribe( - { cwd: REPO_CWD, compare: { mode: "uncommitted" } }, - diffListener, - ); + const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener); await vi.waitFor(() => { expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); @@ -2813,7 +2760,6 @@ describe("WorkspaceGitService checkout observation", () => { }); isDirty = true; diffStat = { additions: 7, deletions: 3 }; - diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }]; getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]?.callback(null, [ { path: path.join(REPO_CWD, "tracked.txt"), type: "update" }, @@ -2828,17 +2774,12 @@ describe("WorkspaceGitService checkout observation", () => { }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: REPO_CWD, - files: [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }], - error: null, - }); + expect(diffListener).toHaveBeenCalledTimes(1); }); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); diffSubscription.unsubscribe(); subscription.unsubscribe(); - diffManager.dispose(); service.dispose(); }); }); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index ea91186e9b..00c7084c7b 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -161,6 +161,7 @@ export interface WorkspaceGitRuntimeSnapshot { isDirty: boolean | null; baseRef: string | null; baseRefExact?: string | null; + comparisonBaseRef?: string | null; aheadBehind: { ahead: number; behind: number } | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -3626,6 +3627,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { : (target.latestGit?.isDirty ?? checkoutStatus.isDirty), baseRef: checkoutStatus.baseRef, baseRefExact: checkoutStatus.baseRefExact, + comparisonBaseRef: checkoutStatus.comparisonBaseRef, aheadBehind: checkoutStatus.aheadBehind, upstreamRef: checkoutStatus.upstreamRef, aheadOfOrigin: checkoutStatus.aheadOfOrigin, diff --git a/packages/server/src/terminal/terminal-serializer.ts b/packages/server/src/terminal/terminal-serializer.ts index 5f79064948..2b36dcbe7b 100644 --- a/packages/server/src/terminal/terminal-serializer.ts +++ b/packages/server/src/terminal/terminal-serializer.ts @@ -16,13 +16,13 @@ export function createTerminalSerializer( // absolutely; an x==cols cursor must keep its pending wrap for the next byte. const position = buffer.cursorX < terminal.cols ? `\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}H` : ""; - return Buffer.from(data + position + serializeCursorPresentation(terminal), "utf8"); + return Buffer.from(data + position + serializeAdditionalModes(terminal), "utf8"); }; } -// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility. -// xterm 6 keeps the program-selected presentation in coreService, not public options. -function serializeCursorPresentation(terminal: TerminalType): string { +// Addon 0.14 omits mouse encoding, DECSCUSR and cursor visibility. xterm 6 +// retains these in internal services rather than the public modes/options. +function serializeAdditionalModes(terminal: TerminalType): string { const core = ( terminal as unknown as { _core: { @@ -30,13 +30,17 @@ function serializeCursorPresentation(terminal: TerminalType): string { isCursorHidden: boolean; decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean }; }; + mouseStateService: { activeEncoding: string }; }; } - )._core.coreService; - const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`; - const cursorStyle = core.decPrivateModes.cursorStyle; + )._core; + let mouseEncoding = ""; + if (core.mouseStateService.activeEncoding === "SGR") mouseEncoding = "\x1b[?1006h"; + else if (core.mouseStateService.activeEncoding === "SGR_PIXELS") mouseEncoding = "\x1b[?1016h"; + const visibility = `${mouseEncoding}\x1b[?25${core.coreService.isCursorHidden ? "l" : "h"}`; + const cursorStyle = core.coreService.decPrivateModes.cursorStyle; // Unset presentation belongs to each renderer's configured defaults. if (cursorStyle === undefined) return visibility; const style = { block: 2, underline: 4, bar: 6 }[cursorStyle]; - return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`; + return `${visibility}\x1b[${style - (core.coreService.decPrivateModes.cursorBlink ? 1 : 0)} q`; } diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 3d1fcca545..d36e86d732 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1702,6 +1702,52 @@ it.each([false, true])( }, ); +it.each([ + { name: "SGR", modes: "\x1b[?1006h", wheel: "\x1b[<64;10;5M" }, + { name: "SGR pixels", modes: "\x1b[?1016h", wheel: "\x1b[<64;80;90M" }, + { name: "disabled SGR", modes: "\x1b[?1006h\x1b[?1006l", wheel: "\x1b[M`*%" }, +])("preserves $name wheel reports across repeated terminal replays", async (input) => { + const source = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true }); + const receiver = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true }); + const serialize = createTerminalSerializer(source); + const wheel = { + button: 4, + action: 0, + col: 10, + row: 5, + x: 80, + y: 90, + ctrl: false, + alt: false, + shift: false, + }; + // Mouse encoding is not exposed by xterm's public modes API. + const mouse = ( + receiver as unknown as { + _core: { + mouseStateService: { + encodeMouseEvent(event: typeof wheel): string; + }; + }; + } + )._core.mouseStateService; + try { + await new Promise((resolve) => + source.write("\x1b[?1049h\x1b[?1002h" + input.modes + "TRANSCRIPT", resolve), + ); + for (let replay = 0; replay < 2; replay++) { + receiver.reset(); + await new Promise((resolve) => receiver.write(serialize(), resolve)); + expect(receiver.buffer.active.type).toBe("alternate"); + expect(receiver.modes.mouseTrackingMode).toBe("drag"); + expect(mouse.encodeMouseEvent(wheel)).toBe(input.wheel); + } + } finally { + receiver.dispose(); + source.dispose(); + } +}); + it.each([ { name: "unset", presentation: "", style: undefined, visible: true }, { diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index 7baac91c23..a19b81ded8 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -3,7 +3,12 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; -import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js"; +import { + getCommitFileDiff, + listCheckoutCommits, + getCheckoutStatus, + getCheckoutDiff, +} from "./checkout-git.js"; import { writePaseoWorktreeMetadata } from "./worktree-metadata.js"; const tempDirs: string[] = []; @@ -393,3 +398,45 @@ describe("listCheckoutCommits", () => { ]); }); }); + +it("compares tag-based worktrees in status, history and legacy diff modes", async () => { + const { repoDir: mainRepoDir, tempDir } = initRepoOnMain(); + const worktreesRoot = join(tempDir, "worktrees"); + const repoDir = join(worktreesRoot, "repo-hash", "tag-feature"); + mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true }); + const context = { worktreesRoot }; + git(["tag", "v1"], mainRepoDir); + git(["worktree", "add", "-b", "tag-feature", repoDir, "v1"], mainRepoDir); + await writePaseoWorktreeMetadata(repoDir, { baseRefName: "refs/tags/v1" }); + commitFile(repoDir, "tag.txt", "committed\n", "tag feature"); + writeFileSync(join(repoDir, "tag.txt"), "edited\n"); + const status = await getCheckoutStatus(repoDir, context); + expect(status).toMatchObject({ + isGit: true, + comparisonBaseRef: "refs/tags/v1", + aheadBehind: { ahead: 1, behind: 0 }, + }); + const history = await listCheckoutCommits({ cwd: repoDir, context }); + expect(history.baseRef).toBe("refs/tags/v1"); + expect(history.commits[0].subject).toBe("tag feature"); + const committed = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "refs/tags/v1" }); + expect(committed.diff).toContain("+committed"); + const all = await getCheckoutDiff(repoDir, { mode: "all", baseRef: "refs/tags/v1" }); + expect(all.diff).toContain("+edited"); +}); + +it("announces a usable comparison base while checked out on the base branch", async () => { + const cwd = makeTempDir(); + git(["init", "-b", "main"], cwd); + git(["config", "user.name", "Test"], cwd); + git(["config", "user.email", "test@example.com"], cwd); + writeFileSync(join(cwd, "a.txt"), "before\n"); + git(["add", "."], cwd); + commit(cwd, "base"); + writeFileSync(join(cwd, "a.txt"), "after\n"); + const status = await getCheckoutStatus(cwd); + expect(status).toMatchObject({ isGit: true, comparisonBaseRef: "refs/heads/main" }); + if (!status.isGit || !status.comparisonBaseRef) throw new Error("Missing comparison base"); + const all = await getCheckoutDiff(cwd, { mode: "all", baseRef: status.comparisonBaseRef }); + expect(all.diff).toContain("+after"); +}); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d793e2c683..2bb295b371 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -40,7 +40,7 @@ import { } from "../services/forge-cli-command.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand, type RunGitCommand } from "./run-git-command.js"; -import { readGitFileContents } from "./git-file-contents.js"; +import { readGitFileContents, readGitFileContentAtRef } from "./git-file-contents.js"; import { unquoteGitPath } from "./git-quoted-path.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { @@ -613,22 +613,6 @@ async function listCheckoutFileChanges( return Array.from(byPath.values()); } -async function readGitFileContentAtRef( - cwd: string, - ref: string, - path: string, -): Promise { - try { - const { stdout } = await runGitCommand(["show", `${ref}:${path}`], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - return stdout; - } catch { - return null; - } -} - async function tryResolveMergeBase(cwd: string, baseRef: string): Promise { try { const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], { @@ -669,7 +653,12 @@ function normalizeNumstatPath(pathField: string): string { } // Parsed patches need a stable wire format regardless of terminal presentation settings. -const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"]; +export const GIT_PATCH_FORMAT_ARGS = [ + "--no-ext-diff", + "--no-color", + "--src-prefix=a/", + "--dst-prefix=b/", +] as const; function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] { return [ @@ -906,6 +895,7 @@ export interface CheckoutStatusGitNonPaseo { isDirty: boolean; baseRef: string | null; baseRefExact?: string | null; + comparisonBaseRef?: string | null; aheadBehind: AheadBehind | null; // Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the // branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are @@ -926,6 +916,7 @@ export interface CheckoutStatusGitPaseo { isDirty: boolean; baseRef: string; baseRefExact?: string | null; + comparisonBaseRef?: string | null; aheadBehind: AheadBehind | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -1748,6 +1739,24 @@ function isQualifiedBranchRef(ref: string): boolean { return ref.startsWith("refs/heads/") || ref.startsWith("refs/remotes/"); } +async function resolveCommitComparisonRef( + cwd: string, + name: string, + context?: CheckoutContext, +): Promise { + await getRunGitCommand(context)(["rev-parse", "--verify", `${name}^{commit}`], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); + const result = await getRunGitCommand(context)(["rev-parse", "--symbolic-full-name", name], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); + return result.stdout.trim() || name; +} + async function resolveBestComparisonBaseRef( cwd: string, baseRef: string, @@ -1774,11 +1783,17 @@ async function resolveBestComparisonBaseRef( return normalized.localName; } - const refName = - baseRef.startsWith("origin/") || baseRef.startsWith("refs/remotes/origin/") - ? normalized.originRef - : normalized.localName; - throw new Error(`Base branch not found locally or on origin: ${refName}`); + return resolveCommitComparisonRef(cwd, baseRef, context); +} + +async function resolveQualifiedComparisonBaseRef( + cwd: string, + baseRef: string, + context: CheckoutContext, +): Promise { + const comparison = context.facts?.isGit ? context.facts.comparisonBaseRef : null; + const ref = comparison ?? (await resolveBestComparisonBaseRef(cwd, baseRef, context)); + return resolveCommitComparisonRef(cwd, ref, context); } async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise { @@ -1801,7 +1816,7 @@ async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise { } } -async function inspectUntrackedFile( +export async function inspectUntrackedFile( cwd: string, relativePath: string, ): Promise<{ stat: FileStat; truncated: boolean }> { @@ -2399,6 +2414,10 @@ export async function getCheckoutStatus( const baseRef = facts.resolvedBaseRef; const mainRepoRoot = facts.mainRepoRoot; const factsContext = { ...context, facts }; + // Ref-derived counts skip the current base branch; the diff consumer still needs its ref. + const comparisonBaseRef = baseRef + ? await resolveQualifiedComparisonBaseRef(cwd, baseRef, factsContext).catch(() => null) + : null; const aheadBehind = baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch, factsContext) @@ -2421,6 +2440,7 @@ export async function getCheckoutStatus( isDirty, baseRef: displayBaseRef ?? baseRef, baseRefExact: baseRef, + comparisonBaseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2440,6 +2460,7 @@ export async function getCheckoutStatus( isDirty, baseRef: displayBaseRef, baseRefExact: baseRef, + comparisonBaseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2804,6 +2825,7 @@ async function readCommitRenames(cwd: string, sha: string): Promise { + if (compare.mode === "uncommitted") return "HEAD"; const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context); const baseRef = resolveOperationBaseRef({ storedBaseRef, @@ -3579,17 +3616,12 @@ async function resolveCheckoutDiffRefs( if (!baseRef) { return null; } - const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef); - return { - baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef, - targetRef: compare.mode === "base" ? "HEAD" : undefined, - includeUntracked: compare.mode === "all", - }; + return resolveBestComparisonBaseRef(cwd, baseRef); } // Git's working-tree diff ignores files absent from the index, even when they // exist in the comparison tree. Restore those entries only in a private index. -async function prepareAllChangesIndex(cwd: string, baseRef: string) { +export async function prepareAllChangesIndex(cwd: string, baseRef: string) { const untracked = await runGitCommand(["ls-files", "-z", "--others", "--exclude-standard"], { cwd, envOverlay: READ_ONLY_GIT_ENV, @@ -3612,21 +3644,41 @@ async function prepareAllChangesIndex(cwd: string, baseRef: string) { entries += result.stdout; } if (!entries) return null; - const directory = await mkdtemp(resolve(tmpdir(), "paseo-all-changes-")); + const snapshot = await snapshotComparisonIndex(cwd); + try { + await runGitCommand(["update-index", "-z", "--index-info"], { + cwd, + input: entries, + envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: snapshot.indexFile }, + }); + return snapshot; + } catch (error) { + await snapshot.cleanup(); + throw error; + } +} + +// write-tree rewrites its index. A private snapshot prevents an index read from +// invalidating the observation that requested it. +export async function snapshotComparisonIndex(cwd: string) { + const directory = await mkdtemp(resolve(tmpdir(), "paseo-comparison-index-")); const indexFile = resolve(directory, "index"); try { const index = await runGitCommand(["rev-parse", "--git-path", "index"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }); - const indexPath = resolveGitRevParsePath(cwd, index.stdout); - if (!indexPath) throw new Error("Git did not return an index path"); - await copyFile(indexPath, indexFile); - await runGitCommand(["update-index", "-z", "--index-info"], { - cwd, - input: entries, - envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile }, - }); + const source = resolveGitRevParsePath(cwd, index.stdout); + if (!source) throw new Error("Git did not return an index path"); + try { + await copyFile(source, indexFile); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; + await runGitCommand(["read-tree", "--empty"], { + cwd, + envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile }, + }); + } return { indexFile, cleanup: () => rm(directory, { recursive: true, force: true }) }; } catch (error) { await rm(directory, { recursive: true, force: true }); diff --git a/packages/server/src/utils/git-file-contents.ts b/packages/server/src/utils/git-file-contents.ts index e42d3c6c83..04a314f17c 100644 --- a/packages/server/src/utils/git-file-contents.ts +++ b/packages/server/src/utils/git-file-contents.ts @@ -1,4 +1,4 @@ -import { runGitCommandBytes } from "./run-git-command.js"; +import { runGitCommandBytes, runGitCommand } from "./run-git-command.js"; // Match the existing git-show ceiling. Oversized batches use bounded per-file reads. const MAX_CONTENT_BYTES = 20 * 1024 * 1024; @@ -46,3 +46,17 @@ export async function readGitFileContents( if (offset !== result.stdout.length) throw new Error("Unexpected git object output"); return contents; } + +export async function readGitFileContentAtRef( + cwd: string, + ref: string, + path: string, +): Promise { + const result = await runGitCommand(["show", `${ref}:${path}`], { + cwd, + envOverlay: { GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, + acceptExitCodes: [0, 128], + }); + if (result.exitCode !== 0 || result.truncated) return null; + return result.stdout; +} diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index 3a3cbf5b4c..e65416878f 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -20,7 +20,7 @@ import { } from "./git-process-scheduler.js"; const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB +export const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB const DEFAULT_STDERR_LIMIT = 2048; let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env })); diff --git a/packages/website/e2e/i18n.spec.ts b/packages/website/e2e/i18n.spec.ts index 02d17e92cb..79fa1fa2bb 100644 --- a/packages/website/e2e/i18n.spec.ts +++ b/packages/website/e2e/i18n.spec.ts @@ -9,6 +9,23 @@ async function chooseLanguage(page: Page, language: string) { await page.getByRole("menuitemradio", { name: language }).click(); } +async function withPageScriptsHeld(page: Page, run: (release: () => void) => Promise) { + let releaseScripts!: () => void; + const scriptsReleased = new Promise((resolve) => { + releaseScripts = resolve; + }); + await page.route("**/*.js", async (route) => { + await scriptsReleased; + await route.continue(); + }); + try { + await run(releaseScripts); + } finally { + releaseScripts(); + await page.unrouteAll({ behavior: "wait" }); + } +} + function describeAlternates(links: Element[]): string[] { return links.map((link) => `${link.getAttribute("hreflang")} ${link.getAttribute("href")}`); } @@ -114,6 +131,7 @@ test("shows the language as an abbreviation and lists every language by name", a test("switches language from the keyboard", async ({ page }) => { await page.goto("/"); + await expect(languageButton(page)).toBeEnabled(); await languageButton(page).focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused(); @@ -183,3 +201,15 @@ test("shows the changelog in Chinese", async ({ page }) => { await expect(page.getByRole("heading", { name: "新增" }).first()).toBeVisible(); await expect(page.getByRole("note")).toHaveCount(0); }); + +test("waits for scripts before enabling the language menu", async ({ page }) => { + await withPageScriptsHeld(page, async (releaseScripts) => { + await page.goto("/docs/cli#running-agents", { waitUntil: "commit" }); + await expect(languageButton(page)).toBeVisible(); + await expect(languageButton(page)).toBeDisabled(); + + releaseScripts(); + await languageButton(page).click(); + await expect(page.getByRole("menuitemradio", { name: "English" })).toBeVisible(); + }); +}); diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 27d4a55c4a..f7d402dd7c 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -181,6 +181,23 @@ test("keeps the directory's ranking window when searching", async ({ page }) => ); }); +test("submits the visible search term when its input event was missed", async ({ page }) => { + await page.goto("/plugins?window=month"); + await expect(page.getByRole("button", { name: "Language: English" }).first()).toBeEnabled(); + await fillSearchWithMissedInputEvent(page, "graphite"); + + await page.keyboard.press("Enter"); + + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), + ).toBeVisible(); + await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( + "aria-current", + "true", + ); +}); + test("searches for a term typed before the page finished loading", async ({ page }) => { const loadScripts = await holdScripts(page); await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" }); @@ -419,6 +436,21 @@ async function searchPlugins(page: Page, term: string) { await page.getByRole("searchbox", { name: "Search plugins" }).fill(term); } +/** Reproduce text entered before hydration's input listener can observe it, without a clock race. */ +async function fillSearchWithMissedInputEvent(page: Page, term: string) { + const search = page.getByRole("searchbox", { name: "Search plugins" }); + await search.evaluate(() => { + for (const eventType of ["input", "change"]) { + document.addEventListener(eventType, (event) => event.stopImmediatePropagation(), { + capture: true, + once: true, + }); + } + }); + await search.fill(term); + await expect(search).toHaveValue(term); +} + async function submitSearch(page: Page, term: string) { await searchPlugins(page, term); await page.keyboard.press("Enter"); diff --git a/packages/website/src/components/language-selector.tsx b/packages/website/src/components/language-selector.tsx index 9fb219fad5..601663f534 100644 --- a/packages/website/src/components/language-selector.tsx +++ b/packages/website/src/components/language-selector.tsx @@ -1,4 +1,4 @@ -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useHydrated, useLocation, useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion, type Transition } from "framer-motion"; import { Check, ChevronDown, Languages } from "lucide-react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; @@ -33,6 +33,7 @@ type OpenFocus = "current" | "first" | "last"; * the menu button pattern: arrows move between languages, Escape and outside presses close it. */ export function LanguageSelector() { + const hydrated = useHydrated(); const { t } = useTranslation(); const locale = useLocale(); const location = useLocation(); @@ -125,6 +126,7 @@ export function LanguageSelector() {