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") },
{