Merge remote-tracking branch 'origin/main' into plan-plugin-api-phase7-composers-by-ref

This commit is contained in:
Mohamed Boudra
2026-11-08 13:34:30 +01:00
184 changed files with 11730 additions and 2777 deletions
@@ -198,3 +198,130 @@ test.describe("Turn end", () => {
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
});
async function expectTimingInsideFooter(page: Page): Promise<void> {
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<void> {
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<number[]> {
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<void> {
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);
});
@@ -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,
@@ -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);
});
+162 -3
View File
@@ -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<void> {
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<DirtyWorkspace> {
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<void> {
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
}
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
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<number, number>();
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<void> {
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<void> {
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<void> {
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<DirtyWorkspace> {
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
async function createWorkspaceWithManyTinyDiffs(
fileCount: number,
branch = "main",
): Promise<DirtyWorkspace> {
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`),
@@ -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();
}
@@ -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 {
@@ -79,6 +79,12 @@ async function previewExplorerResize(page: Page, distance: number) {
return { ...measurement, width: after };
}
async function expectCleanChangesLoaded(page: Page): Promise<void> {
await expect(
page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }),
).toBeVisible();
}
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
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(() =>
@@ -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();
@@ -60,6 +60,15 @@ async function verifyChronologicalFiltering(page: Page): Promise<void> {
});
}
async function verifyHistorySurvivesReload(page: Page): Promise<void> {
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<void> {
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);
@@ -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);
});
});
});
@@ -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<T extends { workspaceId: string }>(
page: Page,
workspaces: T[],
): Promise<T[]> {
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-" });
+84 -10
View File
@@ -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<string[]> {
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 {
@@ -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<SeededWorkspace> {
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<void> {
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<void> {
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<void> {
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();
}
});
});
@@ -219,6 +219,11 @@ export async function expectWorkspaceArchiveOutcome(
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function closeActiveAgentWithShortcut(page: Page, agentId: string): Promise<void> {
await page.keyboard.press("Alt+Shift+w");
await expectWorkspaceTabHidden(page, agentId);
}
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
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<void> {
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
@@ -96,6 +96,35 @@ export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<voi
await expectWorkspaceHoverCardOpen(page);
}
/** Scans the rows in the given order at about a user's pace, then rests on the last one. */
export async function sweepPointerAcrossWorkspaceRows(
page: Page,
workspaceIds: string[],
): Promise<void> {
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<void> {
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<void> {
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
}
@@ -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 () => {
@@ -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<StreamViewportHandle>();
viewportRef.current = viewport;
act(() =>
root.render(
<QueryClientProvider client={queryClient}>
<DisclosureRow rowId={rowId} viewportRef={viewportRef}>
{children}
</DisclosureRow>
</QueryClientProvider>,
),
);
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,
<MessagePreview>
<span style={preLine}>{longText}</span>
</MessagePreview>,
);
// 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,
<PlanCard title="Plan" text="Step one" outcome="approved" />,
);
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(
<QueryClientProvider client={queryClient}>
<PlanCard title="Plan" text="Step one" outcome="pending" />
</QueryClientProvider>,
),
);
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);
});
});
+10 -6
View File
@@ -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<DisclosureHold>(
() => (apply) => {
() => (apply, options) => {
const viewport = viewportRef.current;
if (viewport) {
viewport.holdRow(rowId, apply);
viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top");
} else {
apply();
}
@@ -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<boolean>((resolve) => requests.push(resolve)),
changed: () => {},
scheduleSettle: () => {
settles++;
},
});
return {
pagination,
requests,
get settles() {
return settles;
},
set(patch: Partial<HistoryStartPaginationInput>) {
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<boolean>(() => {});
},
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<boolean>(() => {});
},
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<boolean>((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();
}
},
);
@@ -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<boolean>;
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<typeof setTimeout> | 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,
};
}
@@ -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
@@ -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);
});
});
@@ -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;
}
@@ -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,
};
}
@@ -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);
},
@@ -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;
@@ -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,
@@ -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<RowGeometry, "id" | "top"> | 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<RowGeometry, "id" | "top">) {
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;
},
};
@@ -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),
);
});
});
+12 -11
View File
@@ -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];
+192 -120
View File
@@ -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<LegendListRef>(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<HistoryStartSettleScheduler | null>(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<NativeScrollEvent>) => {
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 (
<View style={historyStartSlotStyle} testID="older-history-slot">
{isLoadingOperation ? (
{isLoadingOperation && (
<View testID="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
{t("paneFind.retry")}
</Button>
)}
</View>
);
}, [historyStartPaginationState]);
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
return (
<LegendList<StreamItem>
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
/>
<View style={viewportStyle}>
<LegendList<StreamItem>
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 ? (
<View pointerEvents="none" testID="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
);
}
@@ -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<StreamRenderInput["segments"]>) => 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);
@@ -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,
+78 -79
View File
@@ -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<HTMLDivElement | null>(null);
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
@@ -351,10 +362,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const readingAnchor = useMemo(createReadingAnchor, []);
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(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 (
<div style={historyStartSlotStyle} data-testid="older-history-slot">
{isLoadingOperation ? (
{isLoadingOperation && (
<div data-testid="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
{t("paneFind.retry")}
</button>
)}
</div>
);
}, [historyStartPaginationState]);
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -1346,7 +1338,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
<div style={streamRowsHostStyle}>
<div ref={rowsHostRef} style={streamRowsHostStyle}>
<div style={virtualRowsContainerStyle} />
{[
...virtualRows.map((virtualRow) => {
@@ -1374,10 +1366,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...mountedRows,
]}
</div>
{liveAuxiliary}
<div ref={auxiliaryRef} style={streamRowStyle}>
{liveAuxiliary}
</div>
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
{!historyStartPaginationState.visible ? (
<div data-testid="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
{scrollEnabled && !isMobileBreakpoint ? (
<DomOverlayScrollbar
scrollContainerRef={scrollContainerRef}
+13 -4
View File
@@ -46,16 +46,23 @@ export interface ScrollToMessageOccurrence {
targetTop(): number | null;
}
/**
* The edge of its row a disclosure's trigger sits on. A header trigger sits on the top: the content
* it opens grows below it. A trigger under the content it opens, such as a long message's show
* more, sits on the bottom.
*/
export type DisclosureTriggerEdge = "top" | "bottom";
export interface StreamViewportHandle {
/** Give keyboard navigation back to the scrolling surface on web. */
focus?: () => 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<StreamViewportHandle | null>;
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<boolean>;
onNearHistoryStart: () => Promise<boolean>;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
@@ -56,7 +56,7 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
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 });
});
});
@@ -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<string, CollapsedTurn>;
/** Rows of expanded turns, which render inside the turn's indent rail. */
nestedItemIds: ReadonlySet<string>;
/** 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<string, string>;
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
hostIdByHiddenRowId: ReadonlyMap<string, string>;
/**
* 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<string>;
summaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
}
export interface CollapseTurnsInput {
@@ -41,39 +31,39 @@ export interface CollapseTurnsInput {
expandedHostIds: ReadonlySet<string>;
/** 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<string, CollapsedTurn>;
/** The previous fold of the same history, whose unchanged summary rows are reused. */
previousSummaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
}
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
const EMPTY_IDS: ReadonlySet<string> = new Set();
const EMPTY_ROWS: ReadonlyMap<string, TurnSummaryItem> = new Map();
const EMPTY_HOSTS: ReadonlyMap<string, string> = 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<string, number>, b: Record<string, number>): boolean {
@@ -81,19 +71,32 @@ function hasSameCounts(a: Record<string, number>, b: Record<string, number>): bo
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
}
function findUpdatedHosts(
previous: ReadonlyMap<string, CollapsedTurn>,
next: ReadonlyMap<string, CollapsedTurn>,
): ReadonlySet<string> {
const updated = new Set<string>();
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<string, CollapsedTurn>();
const summaryRowsByHostId = new Map<string, TurnSummaryItem>();
const nestedItemIds = new Set<string>();
const hostIdByFoldedMessageId = new Map<string, string>();
const hostIdByHiddenRowId = new Map<string, string>();
@@ -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<StreamItem>((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,
};
}
@@ -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
/>
@@ -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(
<CompletedTurnFooterRow
strategy={createNativeStreamStrategy()}
items={[reply]}
timing={timing}
startIndex={0}
supportsTimelineCursor={false}
/>,
),
);
}
/** How many painted lines each footer label takes. */
function paintedLinesByLabel(element: HTMLElement): Record<string, number> {
const lines: Record<string, number> = {};
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,
});
});
@@ -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: {
@@ -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<boolean>;
scheduleSettle: () => void;
}) {
const read = useStableEvent(input.read);
const load = useStableEvent(input.load);
const scheduleSettle = useStableEvent(input.scheduleSettle);
const [state, setState] = useState<HistoryStartPaginationState>(() => ({
status: "dormant",
visible: input.initiallyVisible,
}));
const controller = useRef<ReturnType<typeof createHistoryStartPagination> | null>(null);
if (!controller.current) {
controller.current = createHistoryStartPagination({
initiallyVisible: input.initiallyVisible,
read,
load,
scheduleSettle,
changed: setState,
});
}
return { state, controller: controller.current };
}
+25 -45
View File
@@ -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<AgentStreamViewHandle, AgentStreamVi
turnPresentation,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
isInitialHistoryLoading,
revealInitialHistory,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
@@ -689,26 +693,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
return revealLoadedHistory(messageId);
});
// Read through stable events so a collapse rerun does not change the renderer identity;
// rows whose turn state changed are revised through `historyRowRevision`.
const getCollapsedTurn = useStableEvent((hostId: string) =>
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<StreamItem, { kind: "user_message" }>) => {
const collapsedTurn = getCollapsedTurn(item.id);
const message = (
return (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
@@ -728,25 +729,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
/>
);
if (!collapsedTurn) {
return message;
}
return (
<>
{message}
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
</>
);
},
[
context.capabilities,
agentId,
client,
getCollapsedTurn,
pendingClientMessageIds,
resolvedServerId,
setTurnExpanded,
],
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -931,17 +915,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderToolCallItem(layoutItem, item);
case "tool_call":
return (
<>
{renderToolCallItem(layoutItem, item)}
{getCollapsedTurn(item.id) ? (
<TurnSummaryLine
turn={getCollapsedTurn(item.id)!}
onExpandedChange={setTurnExpanded}
/>
) : null}
</>
);
return renderToolCallItem(layoutItem, item);
case "turn_summary":
return <TurnSummaryLine item={item} onExpandedChange={setTurnExpanded} />;
case "notification":
return <Notification level={item.level} message={item.message} />;
@@ -972,7 +949,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
renderUserMessageItem,
renderAssistantMessageItem,
renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId,
],
@@ -1151,19 +1127,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const { nestedItemIds, updatedHostIds: updatedTurnHostIds } = presentation.turns;
const { nestedItemIds } = presentation.turns;
const historyRowRevision = useMemo(
() => ({
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<AgentStreamViewHandle, AgentStreamVi
[
expandedToolCallGroups,
getToolCallGroup,
expandedTurnHostIds,
isMobile,
nestedItemIds,
presentation.historyGroupUpdatesByHostId,
updatedTurnHostIds,
],
);
@@ -1209,6 +1181,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
@@ -1352,6 +1326,12 @@ function agentStreamViewPropsEqual(
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.isInitialHistoryLoading !== right.isInitialHistoryLoading) {
reasons.push("isInitialHistoryLoading");
}
if (left.revealInitialHistory !== right.revealInitialHistory) {
reasons.push("revealInitialHistory");
}
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
reasons.push("bottomOverlayTailClearance");
}
@@ -65,6 +65,7 @@ export function estimateStreamItemHeight({
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "thought":
case "turn_summary":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "todo_list":
return 144;
+58 -25
View File
@@ -557,6 +557,15 @@ interface AssistantTurnFooterProps {
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
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 (
<View style={assistantTurnFooterStylesheet.container}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{getContent || canFork ? (
<View style={assistantTurnFooterStylesheet.actions}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
</View>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{primaryLabel ? (
<Pressable
style={assistantTurnFooterStylesheet.labelButton}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
>
<View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */}
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
{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. */}
<Text
testID={!showTimestamp ? "turn-footer-timing-label" : undefined}
aria-hidden={showTimestamp}
style={[
assistantTurnFooterStylesheet.label,
showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
]}
>
{primaryLabel}
</Text>
{canSwap ? (
<Text
testID={showTimestamp ? "turn-footer-timing-label" : undefined}
aria-hidden={!showTimestamp}
style={[
assistantTurnFooterStylesheet.label,
assistantTurnFooterStylesheet.overlappingLabel,
!showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
]}
>
{timestampLabel}
</Text>
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : primaryLabel}
</Text>
</View>
) : null}
</Pressable>
) : null}
</View>
+3 -1
View File
@@ -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],
+57 -15
View File
@@ -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 ? (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(null)}
</WindowChromeSafeArea>
) : null,
[panesShareWorkspaceHeader, renderCompactHeader],
);
return (
<RenderProfile id="SplitContainer">
<View style={styles.workspaceShell}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{panesShareWorkspaceHeader ? (
<AdaptiveWorkspaceChrome compactHeader={sharedCompactHeader}>
{renderMainHeader?.()}
</AdaptiveWorkspaceChrome>
) : null}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
@@ -284,7 +306,12 @@ export function SplitContainer({
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
windowChromeCorners={
splitRoot.usesFallbackStrip || panesShareWorkspaceHeader
? "none"
: windowChromeCorners
}
ownsWorkspaceHeader={!panesShareWorkspaceHeader}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={handleTogglePaneMaximized}
@@ -423,6 +450,7 @@ function SplitNodeView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
ownsWorkspaceHeader,
renderMainHeader,
renderCompactHeader,
}: SplitNodeViewProps) {
@@ -511,6 +539,7 @@ function SplitNodeView({
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
ownsWorkspaceHeader={ownsWorkspaceHeader}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
@@ -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(
() => (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
</WindowChromeSafeArea>
),
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
() =>
ownsWorkspaceHeader ? (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader({
tabs: paneTabs,
activeTabKey: activeTabDescriptor?.key ?? "",
actions: compactHeaderActions,
})}
</WindowChromeSafeArea>
) : null,
[
ownsWorkspaceHeader,
renderCompactHeader,
paneTabs,
activeTabDescriptor?.key,
compactHeaderActions,
],
);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
@@ -787,7 +829,7 @@ const SplitPaneView = memo(function SplitPaneView({
testID={`workspace-pane-${pane.id}`}
>
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
{renderMainHeader?.()}
{ownsWorkspaceHeader ? renderMainHeader?.() : null}
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
+75 -71
View File
@@ -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<number, () => 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 (
<HoverCardSurface
{...props}
layer={ctx.layer}
triggerRef={ctx.triggerRef}
contentRef={ctx.contentRef}
/>
<Portal hostName={portalHostName}>
<HoverCardSurface
{...props}
layer={ctx.layer}
triggerRef={ctx.triggerRef}
contentRef={ctx.contentRef}
/>
</Portal>
);
}
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<View | null>;
contentRef: RefObject<View | null>;
}): ReactElement {
const portalHostName = useFloatingPanelPortalHostName();
const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const overlayRef = useRef<View>(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 (
<Portal hostName={portalHostName}>
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={handleLayout}
role={role}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Portal>
<View
ref={overlayRef}
pointerEvents="box-none"
style={[styles.portalOverlay, { zIndex: layer }]}
>
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={place}
role={role}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
);
}
@@ -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 (
<>
@@ -1791,6 +1791,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleSendMessage,
handleQueueMessage,
});
if (hasFinePointer()) {
textInputRef.current?.focus();
}
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
const handleAlternateSendAction = useCallback(() => {
+53 -8
View File
@@ -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<SubscribeCheckoutDiffResponseMessage["payload"]>(
{ 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<DiffState> = {
...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();
+27 -30
View File
@@ -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<SessionOutboundMessage, { type: "status" }>;
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
type ListTerminalsPayload = ListTerminalsResponse["payload"];
interface CheckoutDiffCompare {
@@ -49,7 +46,7 @@ interface CheckoutDiffRoute {
}
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
subscription: OwnedSubscription<DiffState>;
}
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<CheckoutDiffResponsePayload, "requestId"> & { 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,
@@ -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(
@@ -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";
}
+2 -2
View File
@@ -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[];
+1 -1
View File
@@ -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]] : [],
);
}
+19 -6
View File
@@ -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 (
<View style={styles.loadingContainer}>
<View
style={styles.loadingContainer}
accessibilityRole="progressbar"
accessibilityLabel={loadingLabel}
>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
</View>
@@ -1136,12 +1143,16 @@ function DiffBodyContent({
}
if (isDiffLoading) {
return (
<View style={styles.loadingContainer}>
<View
style={styles.loadingContainer}
accessibilityRole="progressbar"
accessibilityLabel={loadingLabel}
>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
</View>
);
}
if (diffTooLarge) {
if (diffTooLarge && !hasChanges) {
return <DiffTooLargeState />;
}
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}
+4 -2
View File
@@ -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<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
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 };
+74
View File
@@ -0,0 +1,74 @@
import type { DiffState, DiffEntry } from "@getpaseo/client";
import type {
DiffError,
ParsedDiffFile,
SubscribeCheckoutDiffRequest,
} from "@getpaseo/protocol/messages";
export type DiffFileHeader = Omit<ParsedDiffFile, "hunks">;
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<SubscribeCheckoutDiffRequest["compare"], "mode" | "baseRef">,
): 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.",
};
}
+5 -5
View File
@@ -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<string, DiffTreeDirNode>([["", root]]);
+4 -4
View File
@@ -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);
}
+26 -1
View File
@@ -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();
});
+19 -42
View File
@@ -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<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
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<CheckoutDiffQueryPayload>({
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,
};
}
+10 -1
View File
@@ -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,
+13 -12
View File
@@ -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
@@ -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([
{
@@ -18,6 +18,7 @@ export interface LoadOlderAgentHistoryClient {
cursor: { epoch: string; seq: number };
limit: number;
projection: "projected";
timeout: number;
},
) => Promise<unknown>;
}
@@ -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);
}
+15
View File
@@ -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<typeof useToastHost>["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}
@@ -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<boolean> => {
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,
@@ -65,6 +65,7 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null {
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
};
case "plugin":
case "turn_summary":
return null;
}
}
@@ -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",
});
@@ -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();
@@ -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);
});
+36
View File
@@ -0,0 +1,36 @@
import type { Logger } from "@getpaseo/client/internal/daemon-client";
export function createHostLogger(
readServerId: () => string | undefined,
output: Pick<Console, "log" | "warn" | "error"> = console,
): Logger {
const format = (record: object, message?: string): string => {
const seen = new WeakSet<object>();
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)),
};
}
+3
View File
@@ -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,
});
@@ -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;
}
}
@@ -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<number | null>(null);
@@ -18,12 +23,21 @@ export function AdaptiveWorkspaceChrome({
return (
<WorkspaceTabMeasurementScope>
<View onLayout={measure}>
{selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
{compactHeader === null
? children
: selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
</View>
</WorkspaceTabMeasurementScope>
);
}
/** 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;
@@ -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 (
<MobileWorkspaceTabSwitcher
paneId={actions.pane?.id}
@@ -3434,14 +3434,11 @@ function WorkspaceScreenContent({
handleCloseTabById,
],
);
// Without a pane's tabs (above a split) the header carries workspace controls only: split panes
// keep their tab rails.
const renderCompactWorkspaceHeader = useCallback(
(
headerTabs: WorkspaceTabDescriptor[],
headerActiveTabKey: string,
actions: WorkspaceCompactHeaderActions,
actionsOnly = false,
) => {
const tabSwitcher = renderWorkspaceTabSwitcher(headerTabs, headerActiveTabKey, actions);
(paneTabs: WorkspaceCompactHeaderTabs | null, actionsOnly = false) => {
const tabSwitcher = paneTabs ? renderWorkspaceTabSwitcher(paneTabs) : null;
return (
<CompactWorkspaceHeader
headerVariant={actionsOnly ? "full" : "compact"}
@@ -3457,7 +3454,7 @@ function WorkspaceScreenContent({
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
>
<WorkspaceHeaderMenuMobile
pane={actions.pane}
pane={paneTabs?.actions.pane}
onExitFocusMode={isFocusModeEnabled ? toggleFocusMode : undefined}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
@@ -3509,21 +3506,17 @@ function WorkspaceScreenContent({
workspacePanelActive,
],
);
const compactWorkspaceHeader = renderCompactWorkspaceHeader(tabs, activeTabKey, {
closeAbove: handleCloseTabsToLeft,
closeBelow: handleCloseTabsToRight,
closeOthers: handleCloseOtherTabs,
});
const fullWorkspaceActions = renderCompactWorkspaceHeader(
const workspaceCompactHeaderTabs = {
tabs,
activeTabKey,
{
actions: {
closeAbove: handleCloseTabsToLeft,
closeBelow: handleCloseTabsToRight,
closeOthers: handleCloseOtherTabs,
},
true,
);
};
const compactWorkspaceHeader = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs);
const fullWorkspaceActions = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs, true);
const desktopSplitContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
return null;
@@ -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<Payload>((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<number | undefined> = [];
const client = {
fetchProviderSubagentTimeline: (
_parent: string,
_child: string,
options: { timeout?: number },
) => {
timeouts.push(options.timeout);
return new Promise<Payload>((_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");
@@ -355,6 +355,35 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
}));
/** Owns child history bootstrap and recovery while a pane observes the child. */
export async function loadOlderProviderSubagentTimeline({
client,
serverId,
parentAgentId,
subagentId,
limit,
}: {
client: Pick<DaemonClient, "fetchProviderSubagentTimeline">;
serverId: string;
parentAgentId: string;
subagentId: string;
limit: number;
}): Promise<boolean> {
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,
+22 -1
View File
@@ -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;
@@ -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<void> {
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<FakeDaemonClient> = {
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" },
{
@@ -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<TClient extends DaemonProbeClient> {
createLogger: typeof createHostLogger;
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null;
@@ -37,6 +39,7 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
createLogger: createHostLogger,
getClientId: getOrCreateClientId,
resolveAppVersion,
createDesktopTransportFactory: createDesktopDaemonTransportFactory,
@@ -317,5 +320,9 @@ export async function connectToDaemon(
deps: DaemonConnectionDependencies<DaemonProbeClient> = 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;
}
@@ -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;
+14
View File
@@ -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() {},
};
+3 -3
View File
@@ -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") },
{
@@ -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(
+2 -1
View File
@@ -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",
@@ -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;
@@ -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<void>((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<void>((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<void>((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();
}
});
});
@@ -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,
+439 -21
View File
@@ -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<void> {
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();
+202 -49
View File
@@ -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<typeof setTimeout>;
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<string, PendingBinaryFileRead>();
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
@@ -1455,6 +1495,7 @@ export class DaemonClient {
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
private runtimeMetrics: DaemonClientRuntimeMetrics | null = null;
private pingProbe: PingProbe | null = null;
private readonly pongWaiters = new Set<PongWaiter>();
private livenessHeartbeatTimer: ReturnType<typeof setTimeout> | 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<number> {
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
let deadline: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_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<number> {
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<number> {
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<number>((resolve, reject) => {
waiter.resolve = resolve;
waiter.reject = reject;
});
let deadline: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_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<T extends CorrelatedResponseType>(
responseType: T,
message: { type: SessionInboundMessage["type"] } & Record<string, unknown>,
options?: { requestId?: string; timeout?: number; signal?: AbortSignal },
options?: {
requestId?: string;
timeout?: number;
signal?: AbortSignal;
validateSnapshot?: (payload: CorrelatedResponsePayload<T>) => void;
},
): OwnedSubscription<CorrelatedResponsePayload<T>> {
let resetSource = false;
return this.owned.observeRequest<CorrelatedResponsePayload<T>>(
@@ -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<DiffState> {
return this.diffStreams.observe(compare);
}
async readDiff(compare: DiffCompare): Promise<DiffState> {
const observation = this.observeDiff(compare);
try {
return await new Promise<DiffState>((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";
+210
View File
@@ -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<string, unknown>[] = [];
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<typeof socket>, 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();
}
},
);
+246
View File
@@ -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<DiffSubscribeRequest, "type" | "requestId">;
export type DiffEntry = Omit<WireEntry, "body">;
export type ResolvedShas = Pick<DiffBegin, "baseSha" | "headSha" | "mergeBaseSha">;
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<DiffSubscribeResponse["payload"]>;
legacy(
cwd: string,
compare: LegacyCompare,
): OwnedSubscription<SubscribeCheckoutDiffResponse["payload"]>;
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<void>;
}
/** Capability selection, slot ownership and reconnect belong to this domain. */
export class DiffStreamRouter {
private registrations = new Set<Registration>();
private slots = new Map<number, { receive(bytes: Uint8Array): void }>();
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<void> {
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<unknown> | null): void {
void backend?.release().catch(this.port.failed);
}
observe(compare: DiffCompare): OwnedSubscription<DiffState> {
let backend: OwnedSubscription<unknown> | 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<SubscriptionObserver<DiffState & { subscriptionId: string }>>();
let resolve!: (state: DiffState & { subscriptionId: string }) => void;
const ready = new Promise<DiffState & { subscriptionId: string }>((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;
}
+86
View File
@@ -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: [] });
});
+94
View File
@@ -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<string, DiffBody> } | null = null;
private committed: Extract<DiffState, { phase: "ready" }> | 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);
}
+2
View File
@@ -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;
@@ -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 `<!doctype html><title>React fill fixture</title><div id="root"></div>
<input aria-label="Plain input" id="plain"><p id="plain-state"></p>
<div role="textbox" aria-label="Editable text" contenteditable="true"></div>
<script>${outputFiles[0].text}</script>`;
}
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 });
}
}
@@ -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 });
@@ -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 };

Some files were not shown because too many files have changed in this diff Show More