mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Merge remote-tracking branch 'origin/main' into plan-plugin-api-phase7-composers-by-ref
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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-" });
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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]] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
@@ -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]]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {},
|
||||
};
|
||||
@@ -261,12 +261,12 @@ export default defineConfig({
|
||||
"@floating-ui/react-native/dist/floating-ui.react-native.esm.js",
|
||||
),
|
||||
},
|
||||
// Point to the ESM build so Vite can transform its imports and apply the
|
||||
// Point to react-native-web's ESM build so Vite can transform its imports and apply the
|
||||
// react alias below (the CJS build uses require('react') which bypasses
|
||||
// Vite alias resolution).
|
||||
// Vite alias resolution). The stub re-exports it and adds what web lacks.
|
||||
{
|
||||
find: "react-native",
|
||||
replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
|
||||
replacement: path.resolve(__dirname, "test-stubs/react-native.mjs"),
|
||||
},
|
||||
{ find: "react", replacement: resolvePackageEntry("react") },
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ const paseoHome = join(root, "home");
|
||||
const cliHome = join(root, "os-home");
|
||||
const workDir = join(root, "work");
|
||||
for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true });
|
||||
const currentProtocolVersion = 5;
|
||||
const currentProtocolVersion = 6;
|
||||
const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null;
|
||||
|
||||
interface Installation {
|
||||
@@ -456,8 +456,8 @@ try {
|
||||
});
|
||||
console.log(`Competing listener owns released port ${releasedPort}`);
|
||||
|
||||
// There is no supported previous wire at protocol 5. Future previous-version classification
|
||||
// is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter.
|
||||
// Protocols 0–4 have no draining adapter. Protocol 5 is now the supported previous wire;
|
||||
// its classification is covered by terminal-worker-protocol.test.ts.
|
||||
for (const protocolVersion of [0, 1, 2, 3, 4]) {
|
||||
console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`);
|
||||
const obsolete = await leaveWorkerWithProtocol(
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"prepack": "npm run build:clean",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { decodeDiffFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
|
||||
import { DiffStream } from "../src/diff/stream.js";
|
||||
const artifacts = resolve(import.meta.dirname, "../../../.artifacts/diff-subscription");
|
||||
const results = [];
|
||||
for (const count of [1, 30]) {
|
||||
const corpus = JSON.parse(await readFile(resolve(artifacts, `corpus-${count}.json`), "utf8")) as {
|
||||
frames: string[];
|
||||
legacy: string;
|
||||
};
|
||||
const frames = corpus.frames.map((value) => Buffer.from(value, "base64"));
|
||||
const binary: number[] = [];
|
||||
const json: number[] = [];
|
||||
for (let iteration = 0; iteration < 15; iteration++) {
|
||||
let files = 0;
|
||||
const stream = new DiffStream((state) => {
|
||||
if (state.phase === "ready") files = state.files.length;
|
||||
});
|
||||
let start = performance.now();
|
||||
for (const frame of frames) stream.receive(decodeDiffFrame(frame));
|
||||
binary.push(performance.now() - start);
|
||||
start = performance.now();
|
||||
validateWSOutboundMessage(JSON.parse(corpus.legacy));
|
||||
json.push(performance.now() - start);
|
||||
if (!files) throw new Error("No materialized files");
|
||||
}
|
||||
const median = (values: number[]) =>
|
||||
values.slice(3).sort((a, b) => a - b)[Math.floor((values.length - 3) / 2)];
|
||||
const result = { count, materializationMs: median(binary), jsonParseValidationMs: median(json) };
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
await writeFile(resolve(artifacts, "client-metrics.json"), JSON.stringify(results, null, 2));
|
||||
if (results.some((result) => result.materializationMs > result.jsonParseValidationMs))
|
||||
process.exitCode = 1;
|
||||
@@ -1,5 +1,9 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { WebSocketServer, type WebSocket as ServerSocket } from "ws";
|
||||
import { exportPublicKey, generateKeyPair } from "@getpaseo/relay/e2ee";
|
||||
import { DaemonClient } from "./daemon-client.js";
|
||||
import { defaultWebSocketFactory } from "./daemon-client-websocket-transport.js";
|
||||
import {
|
||||
createEncryptedTransport,
|
||||
createWebSocketTransportFactory,
|
||||
@@ -190,3 +194,103 @@ test("native drain capability survives WebSocket and encryption wrappers", async
|
||||
});
|
||||
expect(encrypted.supportsDrain).toBe(false);
|
||||
});
|
||||
|
||||
interface ObservedSocket {
|
||||
closeFrame: { code: number; reason: string } | null;
|
||||
}
|
||||
|
||||
/** A daemon endpoint that accepts sockets and never answers, so every attempt times out. */
|
||||
async function silentDaemon() {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
await new Promise<void>((resolve) => server.once("listening", () => resolve()));
|
||||
const sockets: ObservedSocket[] = [];
|
||||
server.on("connection", (socket: ServerSocket) => {
|
||||
const observed: ObservedSocket = { closeFrame: null };
|
||||
sockets.push(observed);
|
||||
socket.on("close", (code, reason) => {
|
||||
observed.closeFrame = { code, reason: reason.toString() };
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing port");
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}/ws`,
|
||||
sockets,
|
||||
openSockets: () => sockets.filter((entry) => entry.closeFrame === null),
|
||||
stop: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
for (const client of server.clients) client.terminate();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Node's global WebSocket applies the WHATWG close rules Chromium and Electron apply:
|
||||
// close() accepts only 1000 or 3000-4999 and a reason of at most 123 UTF-8 bytes.
|
||||
describe("abandoned connections close their WebSocket", () => {
|
||||
test.each([
|
||||
{ name: "direct", e2ee: false },
|
||||
{ name: "relay E2EE", e2ee: true },
|
||||
])(
|
||||
"$name: a timed-out attempt sends a close frame before the client reconnects",
|
||||
async ({ e2ee }) => {
|
||||
const daemon = await silentDaemon();
|
||||
const client = new DaemonClient({
|
||||
url: daemon.url,
|
||||
clientId: "cid_abandoned_close_test",
|
||||
webSocketFactory: defaultWebSocketFactory,
|
||||
connectTimeoutMs: 1_500,
|
||||
reconnect: { enabled: true, baseDelayMs: 50, maxDelayMs: 50 },
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
...(e2ee
|
||||
? {
|
||||
e2ee: {
|
||||
enabled: true,
|
||||
daemonPublicKeyB64: exportPublicKey(generateKeyPair().publicKey),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
try {
|
||||
void client.connect().catch(() => {});
|
||||
await vi.waitFor(() => expect(daemon.sockets.length).toBeGreaterThanOrEqual(2), {
|
||||
timeout: 4_000,
|
||||
});
|
||||
const [abandoned] = daemon.sockets;
|
||||
await vi.waitFor(() => expect(abandoned?.closeFrame).not.toBeNull(), { timeout: 500 });
|
||||
expect(abandoned?.closeFrame?.reason).toBe("Connection timed out");
|
||||
|
||||
// The newest attempt may still be connecting; aborting it closes without a frame.
|
||||
await client.close();
|
||||
await vi.waitFor(() => expect(daemon.openSockets()).toEqual([]), { timeout: 500 });
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
}
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
|
||||
test("a code or reason the WebSocket API rejects still closes the socket", async () => {
|
||||
const daemon = await silentDaemon();
|
||||
try {
|
||||
const transport = createWebSocketTransportFactory(defaultWebSocketFactory)({
|
||||
url: daemon.url,
|
||||
});
|
||||
await new Promise<void>((resolve) => transport.onOpen(() => resolve()));
|
||||
const reason = `Decryption failed: ${"é".repeat(100)}`;
|
||||
|
||||
expect(() => transport.close(1011, reason)).not.toThrow();
|
||||
|
||||
await vi.waitFor(() => expect(daemon.sockets[0]?.closeFrame).not.toBeNull(), {
|
||||
timeout: 500,
|
||||
});
|
||||
const frame = daemon.sockets[0]?.closeFrame;
|
||||
expect(frame?.code).toBe(1000);
|
||||
expect(reason.startsWith(frame?.reason ?? "")).toBe(true);
|
||||
expect(Buffer.byteLength(frame?.reason ?? "")).toBe(123);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
|
||||
// connect timeouts does not crash the CLI with an unhandled error event.
|
||||
const suppressEarlyCloseError = bindTemporaryEarlyCloseErrorHandler(ws);
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
ws.close(toSendableCloseCode(code), toSendableCloseReason(reason));
|
||||
} finally {
|
||||
if (typeof ws.on !== "function" && typeof ws.addEventListener !== "function") {
|
||||
suppressEarlyCloseError();
|
||||
@@ -97,6 +97,32 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
|
||||
};
|
||||
}
|
||||
|
||||
// WHATWG WebSocket.close throws for a code other than 1000 or 3000-4999 and for a
|
||||
// reason over 123 UTF-8 bytes, and the throw leaves the socket open: the peer keeps
|
||||
// streaming into a connection nobody reads. Browsers, Electron and Node's global
|
||||
// WebSocket enforce this; Node's `ws` accepts more. Translate once here so callers
|
||||
// keep their reasons and every runtime sends the same close frame.
|
||||
const MAX_CLOSE_REASON_BYTES = 123;
|
||||
|
||||
function toSendableCloseCode(code: number | undefined): number {
|
||||
if (code === 1000 || (code !== undefined && code >= 3000 && code <= 4999)) return code;
|
||||
return 1000;
|
||||
}
|
||||
|
||||
function toSendableCloseReason(reason: string | undefined): string | undefined {
|
||||
if (reason === undefined) return undefined;
|
||||
const encoder = new TextEncoder();
|
||||
if (encoder.encode(reason).byteLength <= MAX_CLOSE_REASON_BYTES) return reason;
|
||||
let truncated = "";
|
||||
let bytes = 0;
|
||||
for (const character of reason) {
|
||||
bytes += encoder.encode(character).byteLength;
|
||||
if (bytes > MAX_CLOSE_REASON_BYTES) break;
|
||||
truncated += character;
|
||||
}
|
||||
return truncated;
|
||||
}
|
||||
|
||||
function bindWsMessageHandler(
|
||||
ws: WebSocketLike,
|
||||
handler: (data: unknown, isBinary: boolean) => void,
|
||||
|
||||
@@ -728,6 +728,10 @@ class FakeDaemon {
|
||||
this.onClose({ reason });
|
||||
}
|
||||
|
||||
daemonSendsPong(): void {
|
||||
this.onMessage(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
|
||||
triggerError(event?: unknown): void {
|
||||
this.onError(event);
|
||||
}
|
||||
@@ -2605,7 +2609,7 @@ test("keeps default connect timeout shorter than session RPC waiters", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("foreground checks a silently broken socket through two normal heartbeat misses", async () => {
|
||||
test("foreground tears down a silently dead socket after two fresh five-second windows and reconnects at once", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
@@ -2623,14 +2627,23 @@ test("foreground checks a silently broken socket through two normal heartbeat mi
|
||||
await connection;
|
||||
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// The first silent window is followed by a second check at once, not ten seconds later.
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
client.ensureConnected();
|
||||
// The reconnect is immediate: no 1.5s backoff after a foreground liveness teardown.
|
||||
expect(attempts).toBe(2);
|
||||
second.openConnection();
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
// The replacement runs the normal heartbeat cadence.
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(second.pingTimestamps()).toEqual(["20s"]);
|
||||
expect(second.closesFromClient()).toEqual([]);
|
||||
});
|
||||
|
||||
test("foreground verification preserves a healthy socket and deduplicates simultaneous checks", async () => {
|
||||
@@ -2675,22 +2688,26 @@ test("chunks prevent heartbeat misses until silence causes two misses", async ()
|
||||
void client.measureLatency({ timeoutMs: 5_000 }).catch((error: unknown) => {
|
||||
measurementError = error;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// Chunks complete every four seconds for the first 36 seconds; no pong ever arrives.
|
||||
for (let index = 0; index < 9; index++) {
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
expect(measurementError).toEqual(
|
||||
expect.objectContaining({
|
||||
message: "Latency measurement timed out (5000ms)",
|
||||
}),
|
||||
);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await vi.advanceTimersByTimeAsync(index === 0 ? 5_000 : 10_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
// No complete message or pong has arrived, but progress prevents misses.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
// Progress inside the foreground window returned the heartbeat to its normal cadence.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "15s"]);
|
||||
await vi.advanceTimersByTimeAsync(19_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s"]);
|
||||
await vi.advanceTimersByTimeAsync(24_999);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s", "65s"]);
|
||||
expect(daemon.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
});
|
||||
|
||||
@@ -2712,13 +2729,14 @@ test("foreground preserves a multi-MB transfer with its pong queued behind chunk
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(3_001);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
for (let index = 0; index < 5; index++) {
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
// No complete message or pong has arrived, but progress prevents misses.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]);
|
||||
// The first chunk landed seven seconds after foreground: the first window was silent, the
|
||||
// second saw progress, so the cycle ended without a teardown and the cadence is normal again.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "20s"]);
|
||||
daemon.finishTransfer();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
@@ -2742,13 +2760,14 @@ test("chunk progress beyond the daemon lease keeps sending heartbeat pings", asy
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
for (let index = 0; index < 12; index++) {
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
// Each completed window starts the next ping ten seconds later,
|
||||
// even though the response and all queued pongs are still in flight.
|
||||
const now = (index + 1) * 4;
|
||||
const expected = ["0s"];
|
||||
if (index >= 4) expected.push("25s");
|
||||
if (index >= 9) expected.push("50s");
|
||||
if (now >= 15) expected.push("15s");
|
||||
if (now >= 40) expected.push("40s");
|
||||
expect(daemon.pingTimestamps()).toEqual(expected);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
@@ -2758,6 +2777,393 @@ test("chunk progress beyond the daemon lease keeps sending heartbeat pings", asy
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
});
|
||||
|
||||
function connectThroughReplacements(
|
||||
clientId: string,
|
||||
daemons: FakeDaemon[],
|
||||
options?: { reconnect?: { enabled: boolean } },
|
||||
): { client: DaemonClient; attempts: () => number } {
|
||||
let attempts = 0;
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId,
|
||||
logger: noopLogger,
|
||||
...(options?.reconnect ? { reconnect: options.reconnect } : {}),
|
||||
transportFactory: () => {
|
||||
const daemon = daemons[Math.min(attempts, daemons.length - 1)]!;
|
||||
attempts += 1;
|
||||
return daemon.transport;
|
||||
},
|
||||
});
|
||||
clients.push(client);
|
||||
return { client, attempts: () => attempts };
|
||||
}
|
||||
|
||||
test("foreground supersedes a heartbeat that straddled the suspension without counting a miss", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
const { client, attempts } = connectThroughReplacements("resume-before-expiry", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
first.daemonGoesSilent();
|
||||
await vi.advanceTimersByTimeAsync(10_000); // heartbeat ping outstanding when the OS suspends JS
|
||||
await vi.advanceTimersByTimeAsync(14_990); // resume: AppState lands before the stale timer
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "24.99s"]);
|
||||
await vi.advanceTimersByTimeAsync(10); // the stale deadline passes without a miss
|
||||
await vi.advanceTimersByTimeAsync(4_990);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "24.99s", "29.99s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("foreground does not inherit a miss recorded before the app was suspended", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
const { client, attempts } = connectThroughReplacements("resume-after-expiry", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
first.daemonGoesSilent();
|
||||
await vi.advanceTimersByTimeAsync(25_000); // ping at 10s missed at 25s, before or during suspension
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "25s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// Two fresh windows: the old miss would have torn down here.
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "25s", "30s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("a slow but healthy socket survives foreground after a miss before the suspension", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
const { client, attempts } = connectThroughReplacements("resume-slow-pong", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
daemon.daemonGoesSilent();
|
||||
await vi.advanceTimersByTimeAsync(25_000); // one miss
|
||||
daemon.daemonAnswersPingsAfter("5.5s");
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("a pong that arrives after the first foreground window keeps the connection and restores the cadence", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonAnswersPingsAfter("5.5s");
|
||||
const { client, attempts } = connectThroughReplacements("slow-pong", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(55_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(attempts()).toBe(1);
|
||||
// After the late pong the heartbeat runs ten seconds after each settled probe again.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "15.5s", "31s", "46.5s"]);
|
||||
});
|
||||
|
||||
test("a latency measurement outstanding at foreground keeps its own deadline, then the cycle runs", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("latency-then-foreground", [
|
||||
first,
|
||||
second,
|
||||
]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
const measurement = client.measureLatency({ timeoutMs: 1_000 });
|
||||
const measurementError = measurement.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["0s"]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(measurementError).resolves.toEqual(
|
||||
expect.objectContaining({ message: "Latency measurement timed out (1000ms)" }),
|
||||
);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "1s", "6s"]);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("a shared latency probe's activity from before foreground does not satisfy the fresh check", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("stale-activity", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
void client.measureLatency({ timeoutMs: 5_000 }).catch(() => {});
|
||||
first.receiveChunk(true); // a frame arrived while the measurement was outstanding, then the socket died
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["0s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// The measurement's own deadline passed; the foreground cycle starts its first fresh window now.
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("a latency measurement that joined a heartbeat probe survives the foreground check", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonAnswersPingsAfter("2s");
|
||||
const { client, attempts } = connectThroughReplacements("latency-joins-heartbeat", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding
|
||||
const measurement = client.measureLatency({ timeoutMs: 5_000 });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s"]);
|
||||
// The heartbeat's pong lands at 12s and answers the measurement that joined it.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(measurement).resolves.toBe(2000);
|
||||
await vi.advanceTimersByTimeAsync(19_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(attempts()).toBe(1);
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "22s"]);
|
||||
});
|
||||
|
||||
test("a joined latency measurement outlives a silent foreground window until its own deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonAnswersPingsAfter("7s");
|
||||
const { client, attempts } = connectThroughReplacements("latency-outlives-window", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding
|
||||
const measurement = client.measureLatency({ timeoutMs: 10_000 });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000); // the first window closes silent at 16s
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s"]);
|
||||
// The heartbeat's pong lands at 17s, inside the second window and before the 20s deadline.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(measurement).resolves.toBe(7000);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(attempts()).toBe(1);
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s", "27s"]);
|
||||
});
|
||||
|
||||
// Fake timers cannot suspend: a suspension is modelled as the wall clock jumping forward while
|
||||
// the pending window timer stays queued, then firing late on resume.
|
||||
async function suspendFor(ms: number): Promise<void> {
|
||||
vi.setSystemTime(Date.now() + ms);
|
||||
}
|
||||
|
||||
test("a foreground window that spanned a second suspension is discarded when the timer fires first", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-timer", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000); // window 1 silent, window 2 outstanding
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await suspendFor(60_000);
|
||||
daemon.daemonAnswersPingsAfter("fast"); // the socket is healthy on resume
|
||||
await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late
|
||||
client.ensureConnected({ verify: true }); // then AppState lands
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("a foreground window that spanned a second suspension is discarded when AppState fires first", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-appstate", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await suspendFor(60_000);
|
||||
daemon.daemonAnswersPingsAfter("fast");
|
||||
client.ensureConnected({ verify: true }); // AppState lands before the queued timer
|
||||
await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("a dead socket after a second suspension mid-cycle still gets two fresh windows", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-dead", [
|
||||
first,
|
||||
second,
|
||||
]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
first.receiveChunk(true); // a frame before the second suspension is not evidence after it
|
||||
await suspendFor(60_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000); // the late timer discards window 2 and starts over
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s", "15s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("repeated foreground events share one cycle and do not restart its windows", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("repeated-foreground", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
for (let elapsedSeconds = 1; elapsedSeconds <= 9; elapsedSeconds++) {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
}
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
second.openConnection();
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
});
|
||||
|
||||
test("a late pong from the replaced socket does not count for the replacement", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
second.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("late-pong", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(attempts()).toBe(2);
|
||||
second.openConnection();
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(second.pingTimestamps()).toEqual(["10s"]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
first.daemonSendsPong();
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
expect(second.pingTimestamps()).toEqual(["10s", "15s"]);
|
||||
expect(second.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
});
|
||||
|
||||
test("closing the client in the middle of a foreground cycle stops it", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("close-mid-cycle", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await client.close();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1000, reason: "Client closed" }]);
|
||||
expect(attempts()).toBe(1);
|
||||
expect(client.getConnectionState()).toEqual({ status: "disposed" });
|
||||
});
|
||||
|
||||
test("a foreground teardown with reconnect disabled reports the timeout and stays down", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("no-reconnect", [daemon], {
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(client.getConnectionState()).toEqual({
|
||||
status: "disconnected",
|
||||
reason: "Liveness check timed out (5000ms)",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("the immediate reconnect after a foreground teardown backs off normally if it fails", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
const third = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("no-storm", [first, second, third]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(attempts()).toBe(2);
|
||||
second.daemonClosesWith("connection refused");
|
||||
await vi.advanceTimersByTimeAsync(1_499);
|
||||
expect(attempts()).toBe(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts()).toBe(3);
|
||||
third.openConnection();
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
});
|
||||
|
||||
test("an obsolete heartbeat cannot close a replacement connection", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
@@ -2887,6 +3293,18 @@ test("a heartbeat sharing a latency measurement still saves the RTT", async () =
|
||||
expect(session.lastLivenessRttMs()).toBe(2000);
|
||||
});
|
||||
|
||||
test("a measurement that joins an in-flight ping reports that ping's round trip", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession();
|
||||
session.daemonAnswersPingsAfter("2s");
|
||||
await session.connect();
|
||||
await session.advance(11_000); // the heartbeat ping sent at 10s is still outstanding
|
||||
const measurement = session.measureLatency({ timeoutMs: 5_000 });
|
||||
await session.advance(1_000);
|
||||
await expect(measurement).resolves.toBe(2000);
|
||||
expect(session.lastLivenessRttMs()).toBe(2000);
|
||||
});
|
||||
|
||||
test("reports the round-trip time of the last successful heartbeat", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession();
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
DiffStreamRouter,
|
||||
DiffSubscriptionError,
|
||||
type DiffCompare,
|
||||
type DiffState,
|
||||
} from "./diff/index.js";
|
||||
import {
|
||||
ChunkedTransport,
|
||||
ChunkedTransportProtocolError,
|
||||
@@ -1149,6 +1155,12 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
|
||||
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000;
|
||||
const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15_000;
|
||||
// Window of each check after the app returns to the foreground. A socket that silently died
|
||||
// while the app was suspended stays OPEN and accepts sends, so only inbound frames prove it.
|
||||
const FOREGROUND_LIVENESS_TIMEOUT_MS = 5_000;
|
||||
// A foreground window whose timer fires this much later than scheduled (wall clock) spanned
|
||||
// another suspension or a blocked JS thread, so its silence is no evidence either way.
|
||||
const SUSPENDED_WINDOW_SLACK_MS = 2_000;
|
||||
const LIVENESS_FAILURE_RECONNECT_THRESHOLD = 2;
|
||||
|
||||
/** Default timeout for waiting for connection before sending queued messages */
|
||||
@@ -1311,13 +1323,27 @@ interface PingProbe {
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
startedAt: number;
|
||||
// Wall clock, which keeps running while the OS has JS suspended; perfNow may not.
|
||||
startedAtWallMs: number;
|
||||
receivedActivity: boolean;
|
||||
// Whether a timeout on this ping should be recorded as a liveness failure. Only the
|
||||
// heartbeat sets this; a latency measurement never drives teardown, even when a
|
||||
// heartbeat tick shares (dedupes onto) an in-flight measurement ping.
|
||||
drivesLivenessFailure: boolean;
|
||||
role: PingProbeRole;
|
||||
}
|
||||
|
||||
// A latency measurement waits for the next pong, not for a particular probe: the liveness
|
||||
// cycle may replace the probe the measurement joined, and a pong that answers an expired window
|
||||
// still proves the socket. It settles on a pong, a connection failure, or its own deadline.
|
||||
interface PongWaiter {
|
||||
startedAt: number;
|
||||
resolve: (rttMs: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
// Who started the probe. A "latency" probe never drives teardown, even when a heartbeat
|
||||
// tick shares (dedupes onto) it. A "heartbeat" probe runs the regular cadence. A
|
||||
// "foreground" probe belongs to the verification cycle that ensureConnected({ verify })
|
||||
// runs after the app resumes: short window, immediate second check, reconnect at once.
|
||||
type PingProbeRole = "latency" | "heartbeat" | "foreground";
|
||||
|
||||
// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26.
|
||||
export function supportsUsageReports(features: ServerInfoStatusPayload["features"]): boolean {
|
||||
return features?.usageSources === true || features?.providerUsageList === true;
|
||||
@@ -1426,6 +1452,20 @@ export class DaemonClient {
|
||||
private connectionFailure: Error | undefined;
|
||||
private authFailureReasonValue: DaemonAuthFailureReason | null = null;
|
||||
private connectionState: ConnectionState = { status: "idle" };
|
||||
private readonly diffStreams = new DiffStreamRouter({
|
||||
modern: (compare) =>
|
||||
this.observe(
|
||||
"diff.subscribe.response",
|
||||
{ type: "diff.subscribe.request", ...compare },
|
||||
{
|
||||
validateSnapshot: (payload) => {
|
||||
if (payload.error) throw new DiffSubscriptionError(payload.error);
|
||||
},
|
||||
},
|
||||
),
|
||||
legacy: (cwd, compare) => this.observeCheckoutDiff(cwd, compare),
|
||||
failed: (error) => this.logger.error({ err: error }, "Diff observer failed"),
|
||||
});
|
||||
private readonly terminalStreams = new TerminalStreamRouter();
|
||||
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
||||
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
||||
@@ -1455,6 +1495,7 @@ export class DaemonClient {
|
||||
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private runtimeMetrics: DaemonClientRuntimeMetrics | null = null;
|
||||
private pingProbe: PingProbe | null = null;
|
||||
private readonly pongWaiters = new Set<PongWaiter>();
|
||||
private livenessHeartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastLivenessRttMs: number | null = null;
|
||||
private consecutiveLivenessFailures = 0;
|
||||
@@ -1775,10 +1816,12 @@ export class DaemonClient {
|
||||
this.disposeTransport(1000, "Client closed");
|
||||
this.providerSnapshotUpdates.clear();
|
||||
this.clearWaiters(new Error("Daemon client closed"));
|
||||
await this.diffStreams.close();
|
||||
await this.owned.close();
|
||||
this.creations.close();
|
||||
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
||||
this.rejectPingProbe(new Error("Daemon client closed"));
|
||||
this.rejectPongWaiters(new Error("Daemon client closed"));
|
||||
this.terminalStreams.clearSlots();
|
||||
this.lastServerInfoMessage = null;
|
||||
if (this.runtimeMetricsInterval) {
|
||||
@@ -1801,7 +1844,7 @@ export class DaemonClient {
|
||||
this.shouldReconnect = true;
|
||||
}
|
||||
if (this.connectionState.status === "connected") {
|
||||
if (options?.verify) this.tickLivenessHeartbeat();
|
||||
if (options?.verify) this.verifyConnectionOnForeground();
|
||||
return;
|
||||
}
|
||||
if (this.connectionState.status === "connecting") return;
|
||||
@@ -2548,43 +2591,41 @@ export class DaemonClient {
|
||||
|
||||
measureLatency(params?: { timeoutMs?: number }): Promise<number> {
|
||||
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
||||
let deadline: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
deadline = setTimeout(() => reject(new PingTimeoutError(timeoutMs)), timeoutMs);
|
||||
});
|
||||
return Promise.race([
|
||||
this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: false }),
|
||||
timeout,
|
||||
])
|
||||
.catch((error) => {
|
||||
throw toTimeoutError(error, "Latency measurement", timeoutMs);
|
||||
})
|
||||
.finally(() => clearTimeout(deadline));
|
||||
}
|
||||
|
||||
private async livenessPing(params?: { timeoutMs?: number }): Promise<number> {
|
||||
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
||||
try {
|
||||
const rttMs = await this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: true });
|
||||
this.lastLivenessRttMs = rttMs;
|
||||
return rttMs;
|
||||
} catch (error) {
|
||||
throw toTimeoutError(error, "Liveness check", timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
private sendPingAwaitRtt(params: {
|
||||
timeoutMs: number;
|
||||
drivesLivenessFailure: boolean;
|
||||
}): Promise<number> {
|
||||
if (this.connectionState.status !== "connected" || !this.transport) {
|
||||
return Promise.reject(
|
||||
new Error(`Transport not connected (status: ${this.connectionState.status})`),
|
||||
);
|
||||
}
|
||||
const waiter: PongWaiter = { startedAt: perfNow(), resolve: () => {}, reject: () => {} };
|
||||
const pong = new Promise<number>((resolve, reject) => {
|
||||
waiter.resolve = resolve;
|
||||
waiter.reject = reject;
|
||||
});
|
||||
let deadline: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
deadline = setTimeout(() => reject(new PingTimeoutError(timeoutMs)), timeoutMs);
|
||||
});
|
||||
this.pongWaiters.add(waiter);
|
||||
// The probe only puts a ping on the wire; its own outcome is the heartbeat's concern. A
|
||||
// measurement that joins an in-flight ping reports that ping's round trip, so the clock
|
||||
// starts at the send, not at the join.
|
||||
const probe = this.ensurePingProbe({ timeoutMs, role: "latency" });
|
||||
void probe.promise.catch(() => {});
|
||||
waiter.startedAt = probe.startedAt;
|
||||
return Promise.race([pong, timeout])
|
||||
.catch((error) => {
|
||||
throw toTimeoutError(error, "Latency measurement", timeoutMs);
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(deadline);
|
||||
this.pongWaiters.delete(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
// One ping is outstanding at a time; a caller that finds one shares it.
|
||||
private ensurePingProbe(params: { timeoutMs: number; role: PingProbeRole }): PingProbe {
|
||||
if (this.pingProbe) {
|
||||
return this.pingProbe.promise;
|
||||
return this.pingProbe;
|
||||
}
|
||||
|
||||
const startedAt = perfNow();
|
||||
@@ -2600,10 +2641,20 @@ export class DaemonClient {
|
||||
this.pingProbe = null;
|
||||
const error = new PingTimeoutError(timeoutMs);
|
||||
probe.reject(error);
|
||||
if (
|
||||
probe.role === "foreground" &&
|
||||
Date.now() - probe.startedAtWallMs > timeoutMs + SUSPENDED_WINDOW_SLACK_MS
|
||||
) {
|
||||
// The app was suspended again inside this window. Discard it, any frame it saw and
|
||||
// the misses before it; the heartbeat tick that owns this probe starts the cycle over.
|
||||
probe.receivedActivity = false;
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
return;
|
||||
}
|
||||
// Keep the deadline fixed so subsequent pings renew the daemon socket lease
|
||||
// during a long download. Progress proves life even when its pong is queued.
|
||||
if (probe.drivesLivenessFailure && !probe.receivedActivity) {
|
||||
this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs));
|
||||
if (probe.role !== "latency" && !probe.receivedActivity) {
|
||||
this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs), probe.role);
|
||||
}
|
||||
};
|
||||
const probe: PingProbe = {
|
||||
@@ -2613,7 +2664,8 @@ export class DaemonClient {
|
||||
timeoutHandle: setTimeout(expire, timeoutMs),
|
||||
receivedActivity: false,
|
||||
startedAt,
|
||||
drivesLivenessFailure: params.drivesLivenessFailure,
|
||||
startedAtWallMs: Date.now(),
|
||||
role: params.role,
|
||||
};
|
||||
this.pingProbe = probe;
|
||||
|
||||
@@ -2621,7 +2673,8 @@ export class DaemonClient {
|
||||
if (this.pingProbe !== probe) return;
|
||||
const sendError = error instanceof Error ? error : new Error(String(error));
|
||||
this.rejectPingProbe(sendError);
|
||||
if (probe.drivesLivenessFailure) this.recordLivenessFailure(sendError);
|
||||
this.rejectPongWaiters(sendError);
|
||||
if (probe.role !== "latency") this.recordLivenessFailure(sendError, probe.role);
|
||||
};
|
||||
try {
|
||||
void this.sendJsonMessage("ping", "ping", { type: "ping" }).catch(rejectSend);
|
||||
@@ -2629,7 +2682,7 @@ export class DaemonClient {
|
||||
rejectSend(error);
|
||||
}
|
||||
|
||||
return promise;
|
||||
return probe;
|
||||
}
|
||||
|
||||
private startLivenessHeartbeat(): void {
|
||||
@@ -2656,14 +2709,55 @@ export class DaemonClient {
|
||||
}, LIVENESS_HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private tickLivenessHeartbeat(): void {
|
||||
/**
|
||||
* The app came back to the foreground. Start a fresh verification cycle: two
|
||||
* FOREGROUND_LIVENESS_TIMEOUT_MS windows back to back, then teardown and an immediate
|
||||
* reconnect. Misses recorded before the app was suspended do not count, and a heartbeat
|
||||
* probe that straddled the suspension measured the suspension, not the socket, so it is
|
||||
* dropped without a miss. Latency measurements wait for the next pong, so replacing the
|
||||
* probe they joined does not fail them.
|
||||
*/
|
||||
private verifyConnectionOnForeground(): void {
|
||||
// A cycle already running keeps its deadline and its window count.
|
||||
if (this.pingProbe?.role === "foreground") return;
|
||||
if (this.pingProbe?.role === "heartbeat") {
|
||||
this.rejectPingProbe(new Error("Heartbeat superseded by foreground check"));
|
||||
}
|
||||
// A shared latency probe keeps its deadline, but only frames that arrive from now on count
|
||||
// for this check; a latency probe never reads this flag for itself.
|
||||
if (this.pingProbe) this.pingProbe.receivedActivity = false;
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
this.tickLivenessHeartbeat("foreground");
|
||||
}
|
||||
|
||||
private tickLivenessHeartbeat(check: "heartbeat" | "foreground" = "heartbeat"): void {
|
||||
this.stopLivenessHeartbeat();
|
||||
const transport = this.transport;
|
||||
const tick = this.livenessPing({ timeoutMs: LIVENESS_HEARTBEAT_TIMEOUT_MS });
|
||||
void tick
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.transport === transport) this.scheduleNextLivenessHeartbeat();
|
||||
if (this.connectionState.status !== "connected" || !transport) return;
|
||||
const probe = this.ensurePingProbe({
|
||||
timeoutMs:
|
||||
check === "foreground" ? FOREGROUND_LIVENESS_TIMEOUT_MS : LIVENESS_HEARTBEAT_TIMEOUT_MS,
|
||||
role: check,
|
||||
});
|
||||
void probe.promise
|
||||
.then(
|
||||
(rttMs) => {
|
||||
this.lastLivenessRttMs = rttMs;
|
||||
return true;
|
||||
},
|
||||
() => probe.receivedActivity,
|
||||
)
|
||||
.then((alive) => {
|
||||
// A replaced transport, or a probe that superseded this one, owns the next step.
|
||||
if (this.transport !== transport || this.pingProbe) return;
|
||||
if (!alive && check === "foreground") {
|
||||
// Zero inbound frames in a foreground window: check again now. The second silent
|
||||
// window reaches the threshold inside recordLivenessFailure.
|
||||
this.tickLivenessHeartbeat("foreground");
|
||||
return;
|
||||
}
|
||||
this.scheduleNextLivenessHeartbeat();
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2674,7 +2768,12 @@ export class DaemonClient {
|
||||
private observe<T extends CorrelatedResponseType>(
|
||||
responseType: T,
|
||||
message: { type: SessionInboundMessage["type"] } & Record<string, unknown>,
|
||||
options?: { requestId?: string; timeout?: number; signal?: AbortSignal },
|
||||
options?: {
|
||||
requestId?: string;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
validateSnapshot?: (payload: CorrelatedResponsePayload<T>) => void;
|
||||
},
|
||||
): OwnedSubscription<CorrelatedResponsePayload<T>> {
|
||||
let resetSource = false;
|
||||
return this.owned.observeRequest<CorrelatedResponsePayload<T>>(
|
||||
@@ -2700,12 +2799,16 @@ export class DaemonClient {
|
||||
requestType: message.type,
|
||||
error: payload.error,
|
||||
});
|
||||
options?.validateSnapshot?.(payload);
|
||||
accept(payload);
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
resetSource = !(error instanceof DaemonRpcError) && this.isConnected;
|
||||
resetSource =
|
||||
!(error instanceof DaemonRpcError) &&
|
||||
!(error instanceof DiffSubscriptionError) &&
|
||||
this.isConnected;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -4856,6 +4959,26 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
observeDiff(compare: DiffCompare): OwnedSubscription<DiffState> {
|
||||
return this.diffStreams.observe(compare);
|
||||
}
|
||||
|
||||
async readDiff(compare: DiffCompare): Promise<DiffState> {
|
||||
const observation = this.observeDiff(compare);
|
||||
try {
|
||||
return await new Promise<DiffState>((resolve) => {
|
||||
observation.subscribe({
|
||||
snapshot: (state) => {
|
||||
if (state.phase !== "loading") resolve(state);
|
||||
},
|
||||
update: () => {},
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await observation.release();
|
||||
}
|
||||
}
|
||||
|
||||
observeCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
@@ -7180,6 +7303,7 @@ export class DaemonClient {
|
||||
this.chunkedTransport?.close();
|
||||
this.chunkedTransport = null;
|
||||
this.owned.disconnected();
|
||||
this.diffStreams.disconnected();
|
||||
this.providerSnapshotUpdates.clear();
|
||||
this.stopLivenessHeartbeat();
|
||||
this.cleanupTransport();
|
||||
@@ -7312,6 +7436,7 @@ export class DaemonClient {
|
||||
messageType: "pong",
|
||||
});
|
||||
this.resolvePingProbe();
|
||||
this.resolvePongWaiters();
|
||||
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
|
||||
return;
|
||||
}
|
||||
@@ -7370,6 +7495,10 @@ export class DaemonClient {
|
||||
for (const handler of this.hostFrameListeners) handler(hostFrame);
|
||||
return true;
|
||||
}
|
||||
if (rawBytes[0] >= 0x40 && rawBytes[0] <= 0x4f) {
|
||||
this.diffStreams.receive(rawBytes);
|
||||
return true;
|
||||
}
|
||||
if (isPluginFrameOpcode(rawBytes[0])) {
|
||||
this.handlePluginFrame(rawBytes);
|
||||
return true;
|
||||
@@ -7557,6 +7686,8 @@ export class DaemonClient {
|
||||
reason?: string;
|
||||
event?: string;
|
||||
reasonCode?: string;
|
||||
// Attempt now instead of after the backoff delay. Failures of that attempt back off as usual.
|
||||
immediate?: boolean;
|
||||
}): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
@@ -7570,6 +7701,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
this.owned.disconnected();
|
||||
this.diffStreams.disconnected();
|
||||
this.providerSnapshotUpdates.clear();
|
||||
|
||||
// Clear all pending waiters and queued sends since the connection was lost
|
||||
@@ -7586,6 +7718,7 @@ export class DaemonClient {
|
||||
this.clearWaiters(connectionError, true);
|
||||
this.rejectPendingSendQueue(connectionError);
|
||||
this.rejectPingProbe(connectionError);
|
||||
this.rejectPongWaiters(connectionError);
|
||||
this.terminalStreams.clearSlots();
|
||||
this.lastServerInfoMessage = null;
|
||||
|
||||
@@ -7605,6 +7738,10 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input?.immediate) {
|
||||
void this.attemptConnect();
|
||||
return;
|
||||
}
|
||||
this.armReconnectTimer();
|
||||
}
|
||||
|
||||
@@ -7679,7 +7816,18 @@ export class DaemonClient {
|
||||
probe.reject(error);
|
||||
}
|
||||
|
||||
private recordLivenessFailure(error: Error): void {
|
||||
private resolvePongWaiters(): void {
|
||||
const now = perfNow();
|
||||
for (const waiter of this.pongWaiters) waiter.resolve(now - waiter.startedAt);
|
||||
this.pongWaiters.clear();
|
||||
}
|
||||
|
||||
private rejectPongWaiters(error: Error): void {
|
||||
for (const waiter of this.pongWaiters) waiter.reject(error);
|
||||
this.pongWaiters.clear();
|
||||
}
|
||||
|
||||
private recordLivenessFailure(error: Error, role: PingProbeRole): void {
|
||||
this.consecutiveLivenessFailures += 1;
|
||||
if (this.consecutiveLivenessFailures < LIVENESS_FAILURE_RECONNECT_THRESHOLD) {
|
||||
return;
|
||||
@@ -7691,6 +7839,8 @@ export class DaemonClient {
|
||||
reason: error.message,
|
||||
event: "LIVENESS_TIMEOUT",
|
||||
reasonCode: "liveness_timeout",
|
||||
// The foreground cycle already waited two windows; the user is looking at the app.
|
||||
immediate: role === "foreground",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7730,6 +7880,7 @@ export class DaemonClient {
|
||||
this.reconnectAttempt = 0;
|
||||
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
|
||||
this.startLivenessHeartbeat();
|
||||
this.diffStreams.connected(serverInfo.features?.diffSubscription === true);
|
||||
this.owned.restore(serverInfo, {
|
||||
...DEFAULT_CLIENT_CAPABILITIES,
|
||||
...this.config.capabilities,
|
||||
@@ -8007,3 +8158,5 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
||||
cwd: merged.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { DaemonClient, type DaemonTransport } from "../daemon-client.js";
|
||||
import {
|
||||
encodeDiffFrame,
|
||||
DiffOpcode,
|
||||
type DiffFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { DiffState } from "./index.js";
|
||||
function socket() {
|
||||
let message: (data: unknown, binary?: boolean) => void = () => {};
|
||||
let open: () => void = () => {};
|
||||
let close: (event?: unknown) => void = () => {};
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
const transport: DaemonTransport = {
|
||||
send: (data) => {
|
||||
if (typeof data === "string") {
|
||||
const value = JSON.parse(data);
|
||||
if (value.type === "session") {
|
||||
sent.push(value.message);
|
||||
if (value.message.type === "subscription.release.request")
|
||||
queueMicrotask(() =>
|
||||
receive({
|
||||
type: "subscription.release.response",
|
||||
payload: {
|
||||
requestId: value.message.requestId,
|
||||
subscriptionId: value.message.subscriptionId,
|
||||
released: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
close: () => {},
|
||||
onMessage: (handler) => {
|
||||
message = handler;
|
||||
return () => {};
|
||||
},
|
||||
onOpen: (handler) => {
|
||||
open = handler;
|
||||
return () => {};
|
||||
},
|
||||
onClose: (handler) => {
|
||||
close = handler;
|
||||
return () => {};
|
||||
},
|
||||
onError: () => () => {},
|
||||
};
|
||||
const receive = (value: unknown) =>
|
||||
message(JSON.stringify({ type: "session", message: value }), false);
|
||||
return {
|
||||
transport,
|
||||
sent,
|
||||
receive,
|
||||
raw: (bytes: Uint8Array) => message(bytes, true),
|
||||
frame: (frame: DiffFrame) => message(encodeDiffFrame(frame), true),
|
||||
disconnect: () => close({ code: 1001 }),
|
||||
open: (modern = true) => {
|
||||
open();
|
||||
receive({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { ownedSubscriptions: true, diffSubscription: modern, checkoutDiffAll: true },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
const compare = {
|
||||
cwd: "/repo",
|
||||
base: { kind: "rev", rev: "HEAD" },
|
||||
head: { kind: "worktree" },
|
||||
} as const;
|
||||
const begin: DiffFrame = {
|
||||
opcode: DiffOpcode.Begin,
|
||||
slot: 3,
|
||||
revision: 1,
|
||||
baseRevision: 0,
|
||||
baseSha: "a",
|
||||
headSha: "b",
|
||||
mergeBaseSha: "",
|
||||
entries: [],
|
||||
};
|
||||
const end: DiffFrame = {
|
||||
opcode: DiffOpcode.End,
|
||||
slot: 3,
|
||||
revision: 1,
|
||||
diffTooLarge: false,
|
||||
omitted: [],
|
||||
error: null,
|
||||
};
|
||||
async function setup(modern = true) {
|
||||
const wire = socket();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "diff-test",
|
||||
transportFactory: () => wire.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const connecting = client.connect();
|
||||
wire.open(modern);
|
||||
await connecting;
|
||||
return { wire, client };
|
||||
}
|
||||
function acknowledge(wire: ReturnType<typeof socket>, ordinal = 1) {
|
||||
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
|
||||
wire.receive({
|
||||
type: "diff.subscribe.response",
|
||||
payload: {
|
||||
requestId: request?.requestId,
|
||||
subscriptionId: `diff-${ordinal}`,
|
||||
slot: 3,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
test("attaches binary routing during handshake and resubscribes with a full state on reconnect", async () => {
|
||||
const { wire, client } = await setup();
|
||||
try {
|
||||
const states: DiffState[] = [];
|
||||
const observation = client.observeDiff(compare);
|
||||
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
|
||||
acknowledge(wire);
|
||||
wire.frame(begin);
|
||||
wire.frame(end);
|
||||
expect(states.map((state) => state.phase)).toEqual(["loading", "ready"]);
|
||||
wire.disconnect();
|
||||
const connected = client.connect();
|
||||
wire.open();
|
||||
await connected;
|
||||
acknowledge(wire, 2);
|
||||
wire.frame(begin);
|
||||
wire.frame(end);
|
||||
expect(states.map((state) => state.phase)).toEqual(["loading", "ready", "loading", "ready"]);
|
||||
await observation.release();
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
test("an unknown version fails only its subscription and readDiff resolves typed subscribe errors", async () => {
|
||||
const { wire, client } = await setup();
|
||||
try {
|
||||
const observation = client.observeDiff(compare);
|
||||
const states: DiffState[] = [];
|
||||
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
|
||||
acknowledge(wire);
|
||||
const malformed = { ...begin, slot: 3 };
|
||||
const bytes = encodeDiffFrame(malformed);
|
||||
bytes[2] = 255;
|
||||
wire.raw(bytes);
|
||||
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "UNKNOWN" } });
|
||||
const next = client.readDiff(compare);
|
||||
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
|
||||
wire.receive({
|
||||
type: "diff.subscribe.response",
|
||||
payload: {
|
||||
requestId: request?.requestId,
|
||||
subscriptionId: "",
|
||||
slot: 0,
|
||||
error: { code: "INVALID_COMPARE", message: "invalid" },
|
||||
},
|
||||
});
|
||||
expect(await next).toMatchObject({ phase: "error", error: { code: "INVALID_COMPARE" } });
|
||||
expect(client.isConnected).toBe(true);
|
||||
await observation.release();
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
test.each(["uncommitted", "base", "all"] as const)(
|
||||
"old hosts serve the %s comparison through the tagged compatibility route",
|
||||
async (mode) => {
|
||||
const { wire, client } = await setup(false);
|
||||
try {
|
||||
const comparison =
|
||||
mode === "uncommitted"
|
||||
? compare
|
||||
: {
|
||||
...compare,
|
||||
base: { kind: "rev", rev: "refs/tags/v1" } as const,
|
||||
head: mode === "base" ? ({ kind: "rev", rev: "HEAD" } as const) : compare.head,
|
||||
mergeBase: true,
|
||||
};
|
||||
const result = client.readDiff(comparison);
|
||||
const request = wire.sent.find((value) => value.type === "subscribe_checkout_diff_request");
|
||||
expect(request?.compare).toMatchObject({ mode });
|
||||
wire.receive({
|
||||
type: "subscribe_checkout_diff_response",
|
||||
payload: {
|
||||
requestId: request?.requestId,
|
||||
subscriptionId: "legacy",
|
||||
cwd: "/repo",
|
||||
files: [],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
wire.receive({
|
||||
type: "checkout_diff_update",
|
||||
payload: { subscriptionId: "legacy", cwd: "/repo", files: [], error: null },
|
||||
});
|
||||
expect(await result).toMatchObject({ phase: "ready", files: [] });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
decodeDiffFrame,
|
||||
type DiffEntry as WireEntry,
|
||||
type DiffBegin,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type {
|
||||
DiffSubscribeRequest,
|
||||
SubscribeCheckoutDiffRequest,
|
||||
DiffError,
|
||||
DiffSubscribeResponse,
|
||||
SubscribeCheckoutDiffResponse,
|
||||
ParsedDiffFile,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { OwnedSubscription, SubscriptionObserver } from "../connection/index.js";
|
||||
import { DiffStream } from "./stream.js";
|
||||
export type { DiffEndpoint } from "@getpaseo/protocol/messages";
|
||||
export type DiffCompare = Omit<DiffSubscribeRequest, "type" | "requestId">;
|
||||
|
||||
export type DiffEntry = Omit<WireEntry, "body">;
|
||||
export type ResolvedShas = Pick<DiffBegin, "baseSha" | "headSha" | "mergeBaseSha">;
|
||||
|
||||
export type DiffState =
|
||||
| { phase: "loading"; revision: number; entries: DiffEntry[] }
|
||||
| {
|
||||
phase: "ready";
|
||||
revision: number;
|
||||
resolved: ResolvedShas;
|
||||
diffTooLarge: boolean;
|
||||
files: ParsedDiffFile[];
|
||||
}
|
||||
| { phase: "error"; revision: number; error: DiffError };
|
||||
type LegacyCompare = SubscribeCheckoutDiffRequest["compare"];
|
||||
|
||||
interface Port {
|
||||
modern(compare: DiffCompare): OwnedSubscription<DiffSubscribeResponse["payload"]>;
|
||||
legacy(
|
||||
cwd: string,
|
||||
compare: LegacyCompare,
|
||||
): OwnedSubscription<SubscribeCheckoutDiffResponse["payload"]>;
|
||||
failed(error: unknown): void;
|
||||
}
|
||||
export class DiffSubscriptionError extends Error {
|
||||
constructor(readonly detail: DiffError) {
|
||||
super(detail.message);
|
||||
}
|
||||
}
|
||||
interface Registration {
|
||||
start(modern: boolean): void;
|
||||
disconnected(): void;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
/** Capability selection, slot ownership and reconnect belong to this domain. */
|
||||
export class DiffStreamRouter {
|
||||
private registrations = new Set<Registration>();
|
||||
private slots = new Map<number, { receive(bytes: Uint8Array): void }>();
|
||||
private mode: boolean | null = null;
|
||||
constructor(private port: Port) {}
|
||||
connected(modern: boolean): void {
|
||||
this.mode = modern;
|
||||
for (const registration of this.registrations) registration.start(modern);
|
||||
}
|
||||
disconnected(): void {
|
||||
this.mode = null;
|
||||
this.slots.clear();
|
||||
for (const registration of this.registrations) registration.disconnected();
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
await Promise.all([...this.registrations].map((registration) => registration.release()));
|
||||
}
|
||||
receive(bytes: Uint8Array): void {
|
||||
const owner = this.slots.get(bytes[1]);
|
||||
owner?.receive(bytes);
|
||||
}
|
||||
private releaseBackend(backend: OwnedSubscription<unknown> | null): void {
|
||||
void backend?.release().catch(this.port.failed);
|
||||
}
|
||||
observe(compare: DiffCompare): OwnedSubscription<DiffState> {
|
||||
let backend: OwnedSubscription<unknown> | null = null;
|
||||
let state: (DiffState & { subscriptionId: string }) | null = null;
|
||||
let slot: number | null = null;
|
||||
let released = false;
|
||||
let generation = 0;
|
||||
let stream: DiffStream;
|
||||
const observers = new Set<SubscriptionObserver<DiffState & { subscriptionId: string }>>();
|
||||
let resolve!: (state: DiffState & { subscriptionId: string }) => void;
|
||||
const ready = new Promise<DiffState & { subscriptionId: string }>((accept) => {
|
||||
resolve = accept;
|
||||
});
|
||||
const publish = (value: DiffState) => {
|
||||
if (released) return;
|
||||
state = { ...value, subscriptionId: backend?.subscriptionId ?? "" };
|
||||
resolve(state);
|
||||
for (const observer of observers) {
|
||||
try {
|
||||
observer.snapshot(state);
|
||||
} catch (error) {
|
||||
this.port.failed(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
const detach = () => {
|
||||
if (slot !== null) this.slots.delete(slot);
|
||||
slot = null;
|
||||
};
|
||||
const fail = (error: unknown) => {
|
||||
detach();
|
||||
publish({
|
||||
phase: "error",
|
||||
revision: 0,
|
||||
error:
|
||||
error instanceof DiffSubscriptionError
|
||||
? error.detail
|
||||
: { code: "UNKNOWN", message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
this.releaseBackend(backend);
|
||||
};
|
||||
const registration: Registration = {
|
||||
start: (modern) => {
|
||||
if (released || backend) return;
|
||||
const current = ++generation;
|
||||
const failed = (error: unknown) => {
|
||||
if (current === generation) fail(error);
|
||||
};
|
||||
stream = new DiffStream(publish);
|
||||
if (modern) {
|
||||
const observation = this.port.modern(compare);
|
||||
backend = observation;
|
||||
observation.subscribe({
|
||||
snapshot: (payload) => {
|
||||
if (current !== generation) return;
|
||||
detach();
|
||||
if (payload.error) {
|
||||
fail(new DiffSubscriptionError(payload.error));
|
||||
return;
|
||||
}
|
||||
slot = payload.slot;
|
||||
const owner = {
|
||||
receive: (bytes: Uint8Array) => {
|
||||
try {
|
||||
stream.receive(decodeDiffFrame(bytes));
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
this.slots.set(slot, owner);
|
||||
},
|
||||
update: () => {},
|
||||
error: failed,
|
||||
});
|
||||
void observation.ready.catch(failed);
|
||||
} else {
|
||||
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
|
||||
const legacy = legacyCompare(compare);
|
||||
if (!legacy) {
|
||||
fail(
|
||||
new DiffSubscriptionError({
|
||||
code: "INVALID_COMPARE",
|
||||
message: "Update the host to use this comparison",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const observation = this.port.legacy(compare.cwd, legacy);
|
||||
backend = observation;
|
||||
const apply = (
|
||||
payload: Pick<
|
||||
SubscribeCheckoutDiffResponse["payload"],
|
||||
"files" | "error" | "diffTooLarge"
|
||||
>,
|
||||
) => {
|
||||
if (current !== generation) return;
|
||||
if (payload.error)
|
||||
publish({
|
||||
phase: "error",
|
||||
revision: 0,
|
||||
error: {
|
||||
code: payload.error.code === "NOT_GIT_REPO" ? "NOT_GIT_REPO" : "UNKNOWN",
|
||||
message: payload.error.message,
|
||||
},
|
||||
});
|
||||
else
|
||||
publish({
|
||||
phase: "ready",
|
||||
revision: (state?.revision ?? 0) + 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
diffTooLarge: payload.diffTooLarge === true,
|
||||
files: payload.files,
|
||||
});
|
||||
};
|
||||
observation.subscribe({
|
||||
snapshot: apply,
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") apply(message.payload);
|
||||
},
|
||||
error: failed,
|
||||
});
|
||||
void observation.ready.catch(failed);
|
||||
}
|
||||
},
|
||||
disconnected: () => {
|
||||
generation++;
|
||||
detach();
|
||||
state = null;
|
||||
const old = backend;
|
||||
backend = null;
|
||||
void old?.release().catch(this.port.failed);
|
||||
},
|
||||
release: async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
generation++;
|
||||
detach();
|
||||
this.registrations.delete(registration);
|
||||
await backend?.release();
|
||||
observers.clear();
|
||||
},
|
||||
};
|
||||
this.registrations.add(registration);
|
||||
if (this.mode !== null) registration.start(this.mode);
|
||||
return {
|
||||
get subscriptionId() {
|
||||
return backend?.subscriptionId ?? null;
|
||||
},
|
||||
ready,
|
||||
subscribe: (observer) => {
|
||||
observers.add(observer);
|
||||
if (state) observer.snapshot(state);
|
||||
return () => observers.delete(observer);
|
||||
},
|
||||
release: () => registration.release(),
|
||||
};
|
||||
}
|
||||
}
|
||||
function legacyCompare(compare: DiffCompare): LegacyCompare | null {
|
||||
if (compare.highlight === false) return null;
|
||||
const { base, head } = compare;
|
||||
if (base.kind !== "rev") return null;
|
||||
if (base.rev === "HEAD" && head.kind === "worktree")
|
||||
return { mode: "uncommitted", ignoreWhitespace: compare.ignoreWhitespace };
|
||||
if (compare.mergeBase && head.kind === "rev" && head.rev === "HEAD")
|
||||
return { mode: "base", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
|
||||
if (compare.mergeBase && head.kind === "worktree")
|
||||
return { mode: "all", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { DiffStream } from "./stream.js";
|
||||
import { DiffOpcode, type DiffBegin } from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { DiffState } from "./index.js";
|
||||
const entry = {
|
||||
path: "a.ts",
|
||||
oldPath: null,
|
||||
change: "modified" as const,
|
||||
content: "text" as const,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
body: true,
|
||||
};
|
||||
const begin = (revision: number, baseRevision = 0): DiffBegin => ({
|
||||
opcode: DiffOpcode.Begin,
|
||||
slot: 0,
|
||||
revision,
|
||||
baseRevision,
|
||||
baseSha: "a",
|
||||
headSha: "b",
|
||||
mergeBaseSha: "",
|
||||
entries: [entry],
|
||||
});
|
||||
const end = (revision: number) =>
|
||||
({
|
||||
opcode: DiffOpcode.End,
|
||||
slot: 0,
|
||||
revision,
|
||||
diffTooLarge: false,
|
||||
omitted: [],
|
||||
error: null,
|
||||
}) as const;
|
||||
test("publishes the initial tree, commits on End and buffers refreshes with stable equal files", () => {
|
||||
const states: DiffState[] = [];
|
||||
const stream = new DiffStream((state) => states.push(state));
|
||||
stream.receive(begin(1));
|
||||
expect(states[0]).toMatchObject({ phase: "loading", entries: [{ path: "a.ts" }] });
|
||||
stream.receive({
|
||||
opcode: DiffOpcode.Bodies,
|
||||
slot: 0,
|
||||
revision: 1,
|
||||
files: [{ path: "a.ts", hunks: [] }],
|
||||
});
|
||||
expect(states).toHaveLength(1);
|
||||
stream.receive(end(1));
|
||||
const ready = states[1];
|
||||
if (ready.phase !== "ready") throw new Error("Expected ready");
|
||||
stream.receive(begin(2, 1));
|
||||
stream.receive({
|
||||
opcode: DiffOpcode.Bodies,
|
||||
slot: 0,
|
||||
revision: 2,
|
||||
files: [{ path: "a.ts", hunks: [] }],
|
||||
});
|
||||
expect(states).toHaveLength(2);
|
||||
stream.receive(end(2));
|
||||
const refreshed = states[2];
|
||||
if (refreshed.phase !== "ready") throw new Error("Expected ready");
|
||||
expect(refreshed.files[0]).toBe(ready.files[0]);
|
||||
});
|
||||
test("superseded and stale frames cannot leave the client loading; End is authoritative", () => {
|
||||
const states: DiffState[] = [];
|
||||
const stream = new DiffStream((s) => states.push(s));
|
||||
stream.receive(begin(1));
|
||||
stream.receive(begin(2));
|
||||
stream.receive(end(1));
|
||||
expect(() => stream.receive(end(2))).toThrow(/Missing/);
|
||||
stream.receive(begin(3));
|
||||
stream.receive({ ...end(3), error: { code: "REF_NOT_FOUND", message: "gone" } });
|
||||
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "REF_NOT_FOUND" } });
|
||||
stream.receive({ ...begin(4), entries: [] });
|
||||
stream.receive(end(4));
|
||||
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
|
||||
});
|
||||
test("unchanged entries retain hunks, omissions override cache, removed paths disappear", () => {
|
||||
const states: DiffState[] = [];
|
||||
const stream = new DiffStream((s) => states.push(s));
|
||||
stream.receive(begin(1));
|
||||
stream.receive({ ...end(1), omitted: ["a.ts"], diffTooLarge: true });
|
||||
stream.receive({ ...begin(2, 1), entries: [{ ...entry, body: false }] });
|
||||
stream.receive(end(2));
|
||||
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [{ status: "too_large" }] });
|
||||
stream.receive({ ...begin(3, 2), entries: [] });
|
||||
stream.receive(end(3));
|
||||
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
DiffOpcode,
|
||||
materializeDiffFile,
|
||||
type DiffBegin,
|
||||
type DiffBody,
|
||||
type DiffFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import type { DiffState } from "./index.js";
|
||||
|
||||
/** Owns revision assembly. End, rather than a local body count, commits the snapshot. */
|
||||
export class DiffStream {
|
||||
private pending: { begin: DiffBegin; bodies: Map<string, DiffBody> } | null = null;
|
||||
private committed: Extract<DiffState, { phase: "ready" }> | null = null;
|
||||
constructor(private publish: (state: DiffState) => void) {}
|
||||
receive(frame: DiffFrame): void {
|
||||
if (frame.opcode === DiffOpcode.Begin) {
|
||||
if (frame.baseRevision !== 0 && frame.baseRevision !== this.committed?.revision)
|
||||
throw new Error("Unknown diff base revision");
|
||||
if (new Set(frame.entries.map((entry) => entry.path)).size !== frame.entries.length)
|
||||
throw new Error("Duplicate diff entry");
|
||||
this.pending = { begin: frame, bodies: new Map() };
|
||||
if (!this.committed)
|
||||
this.publish({
|
||||
phase: "loading",
|
||||
revision: frame.revision,
|
||||
entries: frame.entries.map(({ body: _body, ...entry }) => entry),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pending = this.pending;
|
||||
if (!pending || frame.revision !== pending.begin.revision) return;
|
||||
if (frame.opcode === DiffOpcode.Bodies) {
|
||||
for (const body of frame.files) {
|
||||
if (pending.bodies.has(body.path)) throw new Error("Duplicate diff body");
|
||||
const entry = pending.begin.entries.find((candidate) => candidate.path === body.path);
|
||||
if (!entry?.body) throw new Error("Unexpected diff body");
|
||||
pending.bodies.set(body.path, body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.pending = null;
|
||||
if (frame.error) {
|
||||
this.committed = null;
|
||||
this.publish({ phase: "error", revision: frame.revision, error: frame.error });
|
||||
return;
|
||||
}
|
||||
const previous = new Map(this.committed?.files.map((file) => [file.path, file]));
|
||||
const omitted = new Set(frame.omitted);
|
||||
for (const path of omitted)
|
||||
if (!pending.begin.entries.some((entry) => entry.path === path))
|
||||
throw new Error("Unknown omitted diff path");
|
||||
const files = pending.begin.entries.map((entry) => {
|
||||
const old = previous.get(entry.path);
|
||||
const body = pending.bodies.get(entry.path);
|
||||
if (entry.content === "text" && !omitted.has(entry.path) && entry.body && !body)
|
||||
throw new Error(`Missing diff body: ${entry.path}`);
|
||||
if (
|
||||
entry.content === "text" &&
|
||||
!entry.body &&
|
||||
!omitted.has(entry.path) &&
|
||||
(!old || pending.begin.baseRevision === 0)
|
||||
)
|
||||
throw new Error(`Missing inherited diff: ${entry.path}`);
|
||||
let file = materializeDiffFile(entry, body ?? null, omitted.has(entry.path));
|
||||
if (entry.content === "text" && !entry.body && !omitted.has(entry.path) && old)
|
||||
file = { ...file, hunks: old.hunks, status: old.status };
|
||||
return old && sameFile(old, file) ? old : file;
|
||||
});
|
||||
const { revision, baseSha, headSha, mergeBaseSha } = pending.begin;
|
||||
this.committed = {
|
||||
phase: "ready",
|
||||
revision,
|
||||
resolved: { baseSha, headSha, mergeBaseSha },
|
||||
files,
|
||||
diffTooLarge: frame.diffTooLarge,
|
||||
};
|
||||
this.publish(this.committed);
|
||||
}
|
||||
}
|
||||
function sameFile(a: ParsedDiffFile, b: ParsedDiffFile): boolean {
|
||||
if (
|
||||
a.path !== b.path ||
|
||||
a.oldPath !== b.oldPath ||
|
||||
a.isNew !== b.isNew ||
|
||||
a.isDeleted !== b.isDeleted ||
|
||||
a.additions !== b.additions ||
|
||||
a.deletions !== b.deletions ||
|
||||
a.status !== b.status
|
||||
)
|
||||
return false;
|
||||
if (a.hunks === b.hunks) return true;
|
||||
return JSON.stringify(a.hunks) === JSON.stringify(b.hunks);
|
||||
}
|
||||
@@ -1377,6 +1377,8 @@ function createGeneratedClientId(): string {
|
||||
return `paseo-sdk-${randomId}`;
|
||||
}
|
||||
|
||||
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
|
||||
|
||||
interface WorkspaceLabelAssignment {
|
||||
client: DaemonClient;
|
||||
workspaceId: string;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { build } from "esbuild";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
export async function buildFillFixture() {
|
||||
const { outputFiles } = await build({
|
||||
stdin: {
|
||||
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
|
||||
contents: `
|
||||
import { createElement as e, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
function Form() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [submitted, setSubmitted] = useState(null);
|
||||
return e('form', { onSubmit: ev => {
|
||||
ev.preventDefault(); setSubmitted({ title, notes });
|
||||
} },
|
||||
e('input', { 'aria-label': 'React title', value: title,
|
||||
onChange: ev => setTitle(ev.target.value) }),
|
||||
e('textarea', { 'aria-label': 'React notes', value: notes,
|
||||
onChange: ev => setNotes(ev.target.value) }),
|
||||
e('p', null, 'React state: ' + JSON.stringify({ title, notes })),
|
||||
e('button', { type: 'submit' }, 'Submit'),
|
||||
e('p', null, 'Submitted: ' + JSON.stringify(submitted)));
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(e(Form));
|
||||
document.getElementById('plain').addEventListener('input', ev => {
|
||||
document.getElementById('plain-state').textContent = 'Plain state: ' + ev.target.value;
|
||||
});
|
||||
`,
|
||||
},
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
});
|
||||
return `<!doctype html><title>React fill fixture</title><div id="root"></div>
|
||||
<input aria-label="Plain input" id="plain"><p id="plain-state"></p>
|
||||
<div role="textbox" aria-label="Editable text" contenteditable="true"></div>
|
||||
<script>${outputFiles[0].text}</script>`;
|
||||
}
|
||||
|
||||
async function findRef(call, browserId, role, name) {
|
||||
const { snapshot } = await call("browser_snapshot", { browserId });
|
||||
const line = snapshot.split("\n").find((candidate) => candidate.includes(`${role} "${name}"`));
|
||||
const ref = line?.match(/\[ref=([^\]]+)\]/)?.[1];
|
||||
expect(ref, `Snapshot must expose ${role} ${name}: ${snapshot}`).toBeTruthy();
|
||||
return ref;
|
||||
}
|
||||
|
||||
async function fillField(call, browserId, name, value) {
|
||||
const ref = await findRef(call, browserId, "textbox", name);
|
||||
await call("browser_fill", { browserId, ref, value });
|
||||
}
|
||||
|
||||
async function submitForm(call, browserId) {
|
||||
const ref = await findRef(call, browserId, "button", "Submit");
|
||||
await call("browser_click", { browserId, ref });
|
||||
}
|
||||
|
||||
async function expectPageText(call, browserId, text) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const result = await call("browser_evaluate", {
|
||||
browserId,
|
||||
function: "() => document.body.innerText",
|
||||
});
|
||||
return JSON.parse(result.resultJson);
|
||||
})
|
||||
.toContain(text);
|
||||
}
|
||||
|
||||
export async function verifyReactFormFill(call, targetUrl) {
|
||||
const { browserId } = await call("browser_new_tab", {
|
||||
url: new URL("/react-fill", targetUrl).href,
|
||||
});
|
||||
try {
|
||||
await call("browser_wait", { browserId, text: "React state:", timeoutMs: 15_000 });
|
||||
await fillField(call, browserId, "Plain input", "plain value");
|
||||
await expectPageText(call, browserId, "Plain state: plain value");
|
||||
await fillField(call, browserId, "Editable text", "editable value");
|
||||
await expectPageText(call, browserId, "editable value");
|
||||
await fillField(call, browserId, "React title", "Hello from browser_fill");
|
||||
await fillField(call, browserId, "React notes", "First line\nSecond line");
|
||||
await expectPageText(
|
||||
call,
|
||||
browserId,
|
||||
'React state: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
|
||||
);
|
||||
await submitForm(call, browserId);
|
||||
await expectPageText(
|
||||
call,
|
||||
browserId,
|
||||
'Submitted: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
|
||||
);
|
||||
await fillField(call, browserId, "React title", "replacement");
|
||||
await fillField(call, browserId, "React notes", "");
|
||||
await submitForm(call, browserId);
|
||||
await expectPageText(call, browserId, 'Submitted: {"title":"replacement","notes":""}');
|
||||
await fillField(call, browserId, "React title", "");
|
||||
await submitForm(call, browserId);
|
||||
await expectPageText(call, browserId, 'Submitted: {"title":"","notes":""}');
|
||||
} finally {
|
||||
await call("browser_close_tab", { browserId });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
||||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||||
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
|
||||
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
|
||||
import { buildFillFixture, verifyReactFormFill } from "./browser-fill.electron.mjs";
|
||||
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
||||
|
||||
import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs";
|
||||
@@ -216,7 +217,13 @@ async function settleDesktopStartup(page) {
|
||||
const CSP_PAGE_PATH = "/csp-no-eval";
|
||||
|
||||
async function startTargetPage() {
|
||||
const fillFixture = await buildFillFixture();
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === "/react-fill") {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(fillFixture);
|
||||
return;
|
||||
}
|
||||
if (request.url === CSP_PAGE_PATH) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
@@ -782,6 +789,7 @@ async function runRegression({
|
||||
document.body.appendChild(overlayRoot);
|
||||
});
|
||||
|
||||
await verifyReactFormFill((name, args) => callBrowserTool(client, name, args), targetUrl);
|
||||
await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl);
|
||||
|
||||
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
|
||||
|
||||
@@ -316,7 +316,16 @@ function buildFillScript(metadata: BrowserRefMetadata, value: string): string {
|
||||
element.focus();
|
||||
const nextValue = ${JSON.stringify(value)};
|
||||
if ('value' in element) {
|
||||
element.value = nextValue;
|
||||
const view = element.ownerDocument.defaultView;
|
||||
let prototype = null;
|
||||
if (element.tagName === 'INPUT') {
|
||||
prototype = view.HTMLInputElement.prototype;
|
||||
} else if (element.tagName === 'TEXTAREA') {
|
||||
prototype = view.HTMLTextAreaElement.prototype;
|
||||
}
|
||||
// Bypass instance setters so framework value trackers observe the input event as a change.
|
||||
if (prototype) Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, nextValue);
|
||||
else element.value = nextValue;
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { ok: true };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user