Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 850471276a fix(diff): keep non-Git workspace refresh successful
Diff invalidation has no target repository for an ordinary folder. Treat that expected absence as a no-op while preserving other root-resolution errors.
2026-11-06 09:18:45 +01:00
Mohamed Boudra a5c5ad8e1d fix(diff): validate Git inputs and resolve refresh roots
Reuse now accounts for effective attributes, configuration, and canonical worktree blobs as well as filesystem state. Explicit refresh resolves repository identity like subscription creation, and callers await that resolution before acknowledging the command.
2026-11-06 09:10:54 +01:00
Mohamed Boudra ad21f9eb93 fix(diff): refresh equivalent worktree paths across platforms
Explicit refreshes must match filesystem-equivalent roots, including Windows path aliases. Use the existing realpath-aware matcher, budget real Git completion waits consistently, and assert optional status metadata in the filesystem commit-file journey.
2026-11-06 08:48:13 +01:00
Mohamed Boudra 8b5527e7e6 fix(diff): align highlight tokens with normalized patch lines
Git can normalize CRLF worktrees while highlighting reads their original line endings. Match tokens to the emitted patch text and trim only a normalization CR before encoding runs; mismatched source tokens are not attached.
2026-11-06 08:14:39 +01:00
Mohamed Boudra 04a7d1b3a7 fix(diff): invalidate timestamp-preserving file replacements
Size and modification time do not identify live file contents after a preserving copy. Include filesystem change time and file identity for tracked and untracked reuse, and verify live revisions agree with fresh reads.
2026-11-06 08:03:49 +01:00
Mohamed Boudra e044c788b5 fix(diff): distinguish gitlink directories from replacements
A raw old gitlink mode also occurs when a submodule becomes a regular file. Query nested Git state only for a current directory, preserving normal file identity for replacements.
2026-11-06 07:53:29 +01:00
Mohamed Boudra 986ce285b0 fix(diff): batch cold reads and refresh logical comparison inputs
Acquire bounded pages of tracked patches lazily after Begin while retaining per-file highlighting and awaited frame delivery. Keep legacy preferred-base selection live across revisions and include submodule Git state in worktree cache identity.
2026-11-06 07:48:26 +01:00
Mohamed Boudra e516961a09 fix(diff): tolerate disappearing highlight sources
Source context is optional after Git has produced the patch. Missing worktree link metadata must preserve the existing hunk-based highlighting fallback instead of failing the revision.
2026-11-06 07:35:28 +01:00
Mohamed Boudra 33db25cf5c fix(diff): highlight symlinks from their stored target text
Working tree source context must match the symlink blob emitted by Git rather than the contents reached by following the link. Otherwise highlight runs do not cover the patch line and encoding fails the revision.
2026-11-06 07:29:33 +01:00
Mohamed Boudra 0a9e678286 fix(diff): preserve BOM text and complete file replacements
Decode each string without consuming its leading BOM. File-type replacements emit multiple patch sections for one listed path, so retain all matching hunks. Assert the optional rename metadata in the existing commit-file journey.
2026-11-06 07:24:19 +01:00
Mohamed Boudra cee05e3c6c fix(diff): preserve nested checkouts and ambiguous patch paths
Only regular files and symlinks are readable untracked entries. Prefer patch metadata to ambiguous unquoted headers so paths containing a prefix delimiter keep their hunks.
2026-11-06 07:12:50 +01:00
Mohamed Boudra 92be3e9dff fix(diff): select renamed patches and read dangling symlinks 2026-11-06 07:03:46 +01:00
Mohamed Boudra 8e1e377120 fix(diff): preserve revision ordering and readable files 2026-11-06 06:50:25 +01:00
Mohamed Boudra 870180cbe5 fix(server): retain the legacy structured diff payload budget 2026-11-06 06:14:33 +01:00
Mohamed Boudra 567c1617f5 feat(app): show the Changes tree while diff bodies stream 2026-11-06 06:07:36 +01:00
Mohamed Boudra 236270768d feat(client): observe streamed diffs with atomic revision updates 2026-11-06 06:04:23 +01:00
Mohamed Boudra 1d8eacd38a feat(server): stream shared diff revisions with paced file bodies 2026-11-06 05:52:32 +01:00
Mohamed Boudra 8bc533279a feat(protocol): define streamed diff revisions and resolve tag bases 2026-11-06 04:01:48 +01:00
137 changed files with 6054 additions and 3578 deletions
-6
View File
@@ -136,12 +136,6 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
This is informational ancestry for future workspace grouping. It does not change placement,
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
never inferred from directories, projects, or Git branches.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
+1 -1
View File
@@ -68,7 +68,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
+2 -3
View File
@@ -226,8 +226,7 @@ its own.
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
card, and are unit-tested. Fix positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
@@ -1,18 +1,6 @@
import {
captureChatHistory,
expectRewoundHistoryIsPrivate,
rewindForkPrompt,
closeChatHistory,
followChatHistorySource,
openChatHistory,
sendChatHistoryFromAnotherClient,
setChatHistoryViewport,
} from "../support/helpers/chat-history";
import { expect, test as base } from "../support/fixtures";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import {
getActiveForkAgentId,
expectActiveForkAgent,
expectChatHistoryAttachment,
expectInFlightForkAvailable,
expectLiveAssistantText,
@@ -50,97 +38,6 @@ const test = base.extend<{
test.describe("Assistant fork menu", () => {
test.describe.configure({ timeout: 180_000 });
test("reads fork history before and after sending and opens its source after reload", async ({
page,
seedForkWorkspace,
}, testInfo) => {
const initialPrompt = "emit 1 coalesced agent stream updates for readable fork history.";
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-readable-",
title: "Original conversation",
initialPrompt,
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await forkMostRecentAssistantTurnToNewTab(page);
await openChatHistory(page, "draft", [initialPrompt]);
await captureChatHistory(page, testInfo, "history-wide.png");
await closeChatHistory(page);
await submitMessage(page, "emit 1 coalesced agent stream updates for the fork.");
const forkAgentId = await getActiveForkAgentId(page);
await session.client.waitForFinish(forkAgentId, 45_000);
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
await page.reload();
await setChatHistoryViewport(page, "compact");
await openChatHistory(page, "sent", [initialPrompt]);
await captureChatHistory(page, testInfo, "history-compact.png");
await followChatHistorySource(page);
await setChatHistoryViewport(page, "wide");
await expectActiveForkAgent(page, session.agentId);
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
await expectActiveForkAgent(page, forkAgentId);
await forkMostRecentAssistantTurnToNewTab(page);
await openChatHistory(page, "draft", [initialPrompt, "updates for the fork."]);
await followChatHistorySource(page);
await expectActiveForkAgent(page, forkAgentId);
});
for (const prompt of ["", "Continue the fork"]) {
test(`retains inherited context when rewinding the first fork prompt (${prompt || "history only"})`, async ({
page,
seedForkWorkspace,
}) => {
const initialPrompt = "emit 1 coalesced agent stream updates for rewindable fork history.";
const session = await seedForkWorkspace({
repoPrefix: "fork-rewind-",
title: "Rewind source",
initialPrompt,
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await forkMostRecentAssistantTurnToNewTab(page);
await submitMessage(page, prompt);
const forkId = await getActiveForkAgentId(page);
await session.client.waitForFinish(forkId, 45_000);
await page.reload();
await openChatHistory(page, "sent", [initialPrompt]);
await closeChatHistory(page);
await rewindForkPrompt(page);
await expectRewoundHistoryIsPrivate(page, session.agentId, forkId);
await openChatHistory(page, "draft", [initialPrompt]);
await closeChatHistory(page);
await submitMessage(page, prompt);
await session.client.waitForFinish(forkId, 45_000);
await page.reload();
await openChatHistory(page, "sent", [initialPrompt]);
});
}
test("shows history sent from another connected client without reloading", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-live-history-",
title: "Live history recipient",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
const history = "[User] Context inherited on another device.";
await sendChatHistoryFromAnotherClient(session, history);
await openChatHistory(page, "sent", [history]);
await session.client.waitForFinish(session.agentId, 45_000);
});
test("forks a failed assistant turn that has no provider message id", async ({
page,
seedForkWorkspace,
+162 -3
View File
@@ -8,7 +8,11 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import {
openChangesPanel,
openChangesTreePanel,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
@@ -262,6 +266,126 @@ test.afterEach(async () => {
}
});
test("Changes without a comparison base explains how to view pending changes", async ({ page }) => {
const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev");
await openWorkspaceChangesTree(page, workspace);
await expect(
page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."),
).toBeVisible();
await selectChangesComparison(page, "Uncommitted");
await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible();
});
async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesTreePanel(page);
}
test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await createLargeStreamedWorkspace();
await addOversizedSibling(workspace);
const stream = await holdDiffCompletion(page);
await openWorkspaceChangesSurface(page, workspace, 90_000);
await expectAnnouncedTreeAndLoader(page);
await page.screenshot({ path: testInfo.outputPath("tree-loading.png") });
stream.finish();
await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0);
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
const connections = stream.connections;
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1");
await addOneLineToFirstFile(workspace);
await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1);
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1");
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
expect(stream.connections).toBe(connections);
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
});
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
const workspace = await createWorkspaceWithManyTinyDiffs(252);
await Promise.all(
Array.from({ length: 252 }, (_, file) =>
writeFile(
path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`),
Array.from(
{ length: 90 },
(_unused, line) =>
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
).join("\n") + "\n",
),
),
);
return workspace;
}
async function addOversizedSibling(workspace: DirtyWorkspace): Promise<void> {
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
}
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
const file = path.join(workspace.repoPath, "src/file-0000.ts");
await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n");
}
async function holdDiffCompletion(page: Page) {
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
let holding = true;
let connections = 0;
let completed = 0;
const completions: Array<() => void> = [];
const changedBodies: number[] = [];
const bodyCounts = new Map<number, number>();
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
connections++;
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => serverSocket.send(message));
serverSocket.onMessage((message) => {
if (
typeof message !== "string" &&
message[0] >= DiffOpcode.Begin &&
message[0] <= DiffOpcode.End
) {
const frame = decodeDiffFrame(message);
if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0);
else if (frame.opcode === DiffOpcode.Bodies)
bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length);
else {
completed++;
changedBodies.push(bodyCounts.get(frame.slot) ?? 0);
}
}
if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) {
completions.push(() => browserSocket.send(message));
return;
}
browserSocket.send(message);
});
});
return {
changedBodies,
get connections() {
return connections;
},
get completed() {
return completed;
},
finish: () => {
holding = false;
completions.splice(0).forEach((send) => send());
},
};
}
async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise<void> {
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout });
}
async function expectAnnouncedTreeAndLoader(page: Page): Promise<void> {
await expect(changesTree(page).first()).toBeVisible();
await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible();
}
test("Changes opens All changes for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
@@ -490,6 +614,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,
}) => {
@@ -1302,11 +1455,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);
@@ -1885,12 +2040,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`),
@@ -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,127 +0,0 @@
import { expect, test } from "../support/fixtures";
import { addProjectFlow } from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
chooseFooterOverflowAction,
expectFooterLine,
expectFooterMenusInsideViewport,
expectFooterNeverClipped,
expectFooterOverflowActions,
footerOverflowMenu,
openFooterOverflow,
seedDockedSidebarWidth,
watchFooterClipping,
} from "../support/helpers/sidebar-footer-overflow";
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
const TOUCH_CONTROL = 44;
const MIN_SIDEBAR_WIDTH = 200;
const ALL_INLINE = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
];
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
const NARROW_TOUCH_LINE = [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-footer-overflow",
"sidebar-settings",
];
test.describe("Sidebar footer on a touch screen", () => {
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
page,
}) => {
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
await watchFooterClipping(page);
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await test.step("Help opens as a page of the overflow menu", async () => {
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await chooseFooterOverflowAction(page, "Help and support");
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(footerOverflowMenu(page)).toBeHidden();
});
await test.step("Usage opens from the overflow menu", async () => {
await openFooterOverflow(page);
await chooseFooterOverflowAction(page, "Usage");
await expect(footerOverflowMenu(page)).toBeHidden();
await expect(page.getByTestId("usage-modal")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("usage-modal")).toBeHidden();
});
await test.step("Add project and Settings shortcuts still work", async () => {
await page.keyboard.press("Control+o");
await expect(addProjectFlow(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await page.keyboard.press("Control+,");
await expect(page).toHaveURL(/\/settings\/general$/);
});
});
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
});
});
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
test("the overflow menu reaches the hidden actions", async ({ page }) => {
await watchFooterClipping(page);
await gotoAppShell(page);
await openCompactSidebar(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await expectFooterMenusInsideViewport(page);
});
});
test.describe("Sidebar footer on a short, narrow phone", () => {
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
await gotoAppShell(page);
await addOfflineHostsAndReload(
page,
Array.from({ length: 10 }, (_, index) => ({
serverId: `overflow-host-${index}`,
label: `Overflow host ${index}`,
})),
);
await openCompactSidebar(page);
await expectFooterLine(page, {
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
controlSize: TOUCH_CONTROL,
});
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
await expectFooterMenusInsideViewport(page);
await chooseFooterOverflowAction(page, "Hosts");
await expectFooterMenusInsideViewport(page);
const addHost = page.getByRole("menuitem", { name: "Add host" });
await addHost.scrollIntoViewIfNeeded();
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
await expect(addHost).toBeInViewport();
await addHost.click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
});
});
@@ -8,7 +8,7 @@ import {
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
import { openSettingsHostSection } from "../support/helpers/settings";
import {
expectFooterItemSetting,
expectFooterSeparator,
@@ -42,7 +42,6 @@ import {
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { createTempGitRepo } from "../support/helpers/workspace";
import { buildOpenProjectRoute } from "@/utils/host-routes";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -71,112 +70,7 @@ async function footerClip(page: Page) {
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
}
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
test.describe("Usage item", () => {
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
let usedPct = 31;
const usage = await installUsageReportsFixture(page, {
lists: [
() => [
{
...claudeAndCodexReports()[0]!,
report: {
status: "available",
windows: [
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
],
},
},
],
],
});
await page.clock.install();
await page.setViewportSize(WIDE);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h"]);
const startingUrl = page.url();
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
const requestsBeforeRefresh = usage.listRequests();
usedPct = 42;
await page.clock.fastForward(5 * 60_000);
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
await expectPinnedUsage(page, ["42% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await expect(page).toHaveURL(startingUrl);
expect(usage.listRequests()).toEqual([
...requestsBeforeRefresh,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await setPageVisibility(page, "hidden");
const requestsBeforeBackgrounding = usage.listRequests();
usedPct = 47;
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
await expectPinnedUsage(page, ["42% 5h"]);
await setPageVisibility(page, "visible");
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["47% 5h"]);
expect(usage.listRequests()).toEqual([
...requestsBeforeBackgrounding,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await page.setViewportSize(COMPACT);
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeHiding = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
await openCompactSidebar(page);
await expectPinnedUsage(page, ["47% 5h"]);
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
usedPct = 53;
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["53% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await page.setViewportSize(WIDE);
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", false);
await leaveSettings(page);
await expectNoUsageItem(page);
const requestsBeforeDisabling = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
});
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.clock.install();
await page.setViewportSize(COMPACT);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsHostSection(page, getServerId(), "usage");
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeWaiting = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
});
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
await page.setViewportSize(COMPACT);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -112,21 +112,3 @@ export async function expectLiveAssistantText(page: Page, text: string): Promise
timeout: 60_000,
});
}
export async function getActiveForkAgentId(page: Page): Promise<string> {
const selectedTab = page
.getByTestId("workspace-tabs-row")
.getByRole("button")
.and(page.locator('[aria-selected="true"]'));
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-agent_/);
const tabId = await selectedTab.getAttribute("data-testid");
if (!tabId) throw new Error("Expected a selected agent tab");
return tabId.replace("workspace-tab-agent_", "");
}
export async function expectActiveForkAgent(page: Page, agentId: string) {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveAttribute(
"aria-selected",
"true",
);
}
@@ -1,82 +0,0 @@
import { expect, type Page, type TestInfo } from "@playwright/test";
import type { MockAgentWorkspace } from "./mock-agent";
import { getServerId } from "./server-id";
export function setChatHistoryViewport(page: Page, layout: "wide" | "compact") {
return page.setViewportSize(
layout === "wide" ? { width: 1280, height: 720 } : { width: 390, height: 844 },
);
}
export async function openChatHistory(page: Page, location: "draft" | "sent", contents: string[]) {
const testId =
location === "draft"
? "composer-chat-history-attachment-pill"
: "message-chat-history-attachment";
const attachment = page.getByTestId(testId).filter({ visible: true }).last();
await expect(attachment).toBeVisible({ timeout: 10_000 });
await attachment.click();
for (const text of contents) {
await expect(page.getByTestId("chat-history-content")).toContainText(text);
}
await expect(page.getByRole("button", { name: "Open source chat" })).toBeVisible();
}
export async function closeChatHistory(page: Page) {
await page
.getByTestId("chat-history-preview")
.getByRole("button", { name: "Close", exact: true })
.click();
}
export async function followChatHistorySource(page: Page) {
await page.getByRole("button", { name: "Open source chat" }).click();
await expect(page.getByTestId("chat-history-preview")).not.toBeVisible();
}
export async function captureChatHistory(page: Page, testInfo: TestInfo, name: string) {
// Follow the existing QA helpers: settle spring animations and hide Expo-only chrome.
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: testInfo.outputPath(name), animations: "disabled" });
}
export async function sendChatHistoryFromAnotherClient(
session: MockAgentWorkspace,
history: string,
) {
const serverId = getServerId();
await session.client.sendAgentMessage(session.agentId, "", {
attachments: [
{
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: history,
chatHistorySource: { serverId, agentId: "source-agent", title: "Original conversation" },
},
],
});
}
export async function rewindForkPrompt(page: Page) {
const prompt = page.getByTestId("user-message").filter({ visible: true }).last();
await prompt.hover();
const trigger = prompt.getByRole("button", { name: "Rewind to this message", exact: true });
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const option = page.getByRole("menuitem", { name: "Rewind conversation", exact: true });
await expect(option).toBeInViewport({ ratio: 1 });
await option.click();
await expect(option).not.toBeVisible();
}
export async function expectRewoundHistoryIsPrivate(page: Page, sourceId: string, forkId: string) {
await page.getByTestId(`workspace-tab-agent_${sourceId}`).click();
const history = page
.getByTestId("composer-chat-history-attachment-pill")
.filter({ visible: true });
await expect(history).toHaveCount(0);
await page.getByTestId(`workspace-tab-agent_${forkId}`).click();
await expect(history).toBeVisible();
}
+11 -25
View File
@@ -22,27 +22,15 @@ export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
export async function addOfflineHostsAndReload(
page: Page,
hosts: Array<{ serverId: string; label: string }>,
options: { primaryLabel?: string } = {},
): Promise<void> {
const nowIso = new Date().toISOString();
const offlineHosts = hosts.map((host) =>
buildSeededHost({
serverId: host.serverId,
label: host.label,
endpoint: "127.0.0.1:59999",
nowIso,
}),
);
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ hosts: added, keys, primaryLabel }) => {
({ host, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
@@ -52,22 +40,20 @@ export async function addOfflineHostsAndReload(
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
for (const host of added) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
hosts: offlineHosts,
host: offlineHost,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: options.primaryLabel,
primaryLabel: input.primaryLabel,
},
);
@@ -157,10 +157,7 @@ export interface SeedDaemonClient {
sendAgentMessage(
agentId: string,
text: string,
options?: {
sourceAgentId?: string;
attachments?: import("@getpaseo/protocol/messages").AgentAttachment[];
},
options?: { sourceAgentId?: string },
): Promise<void>;
waitForFinish(
agentId: string,
@@ -1,127 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** Every footer control that can sit in the icon line, caret included. */
const FOOTER_LINE_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-footer-overflow",
"sidebar-settings",
];
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
const GEOMETRY_TOLERANCE = 0.01;
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
await page.addInitScript((sidebarWidth) => {
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
}, width);
}
/**
* From the first frame, record any frame in which a footer button reaches past its row, so a row
* laid out before it measured shows up even if it lasted one frame.
*/
export async function watchFooterClipping(page: Page): Promise<void> {
await page.addInitScript((testIDs) => {
const clippedFrames: string[] = [];
Object.assign(window, { __footerClippedFrames: clippedFrames });
const sample = () => {
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
const rowRect = row.getBoundingClientRect();
if (rowRect.width === 0) continue;
for (const testID of testIDs) {
const button = row.querySelector(`[data-testid="${testID}"]`);
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
clippedFrames.push(testID);
}
}
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, FOOTER_LINE_TEST_IDS);
}
export async function expectFooterNeverClipped(page: Page): Promise<void> {
const clipped = await page.evaluate(
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
);
expect(clipped).toEqual([]);
}
function footerLine(page: Page): Locator {
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
}
export function footerOverflowMenu(page: Page): Locator {
return page.getByTestId("sidebar-footer-overflow-menu");
}
/**
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
* line's box, so no button is cut off by the sidebar's edge.
*/
export async function expectFooterLine(
page: Page,
input: { testIDs: string[]; controlSize: number },
): Promise<void> {
const line = footerLine(page);
await expect(line).toHaveCount(1);
await expect
.poll(async () =>
line
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
)
.toEqual(input.testIDs);
const { row, buttons } = await line.evaluate((element, testIDs) => {
const rect = (node: Element) => {
const { x, width, height } = node.getBoundingClientRect();
return { x, width, height };
};
return {
row: rect(element),
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
};
}, input.testIDs);
for (const button of buttons) {
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
}
}
export async function openFooterOverflow(page: Page): Promise<void> {
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
await expect(footerOverflowMenu(page)).toBeVisible();
}
/** The overflow menu lists exactly these actions, in row order. */
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
}
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
}
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
const viewport = page.viewportSize()!;
const rows = page.getByRole("menuitem");
await expect(rows.first()).toBeVisible();
const boxes = await rows.evaluateAll((elements) =>
elements.map((element) => {
const { x, width } = element.getBoundingClientRect();
return { x, width };
}),
);
for (const box of boxes) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
}
}
@@ -49,7 +49,7 @@ function getTextAttachmentSubtitle(
t: TFunction,
): string {
if (attachment.contextKind === "chat_history") {
return attachment.chatHistorySource?.title ?? t("message.chatHistory.previousConversation");
return "Previous conversation";
}
return t("message.attachments.text");
}
@@ -87,13 +87,6 @@ export function getAgentAttachmentPillContent(
subtitle: `Issue #${attachment.number}`,
};
case "text":
if (attachment.contextKind === "chat_history") {
return {
icon: attachmentFileIcon,
title: t("message.chatHistory.title"),
subtitle: getTextAttachmentSubtitle(attachment, t),
};
}
if (attachment.externalResource) {
return {
icon: attachmentGithubIssueIcon,
@@ -1,125 +0,0 @@
import { generateMessageId } from "@/types/stream";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import type { ChatHistoryAttachment as HistoryAttachment } from "@getpaseo/protocol/messages";
import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ArrowUpRight } from "lucide-react-native";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { AttachmentFrame, AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import { AdaptiveModalSheet, FULL_SHEET_SNAP_POINTS } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { navigateToAgent } from "@/utils/navigate-to-agent";
interface ChatHistoryAttachmentProps {
attachment: Extract<AgentAttachment, { type: "text" }>;
onRemove?: () => void;
disabled?: boolean;
testID?: string;
}
export function ChatHistoryAttachment({
attachment,
onRemove,
disabled = false,
testID,
}: ChatHistoryAttachmentProps) {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const open = useCallback(() => setVisible(true), []);
const close = useCallback(() => setVisible(false), []);
const source = attachment.chatHistorySource;
const openSource = useCallback(() => {
if (!source) return;
close();
navigateToAgent({ serverId: source.serverId, agentId: source.agentId });
}, [close, source]);
const header = useMemo(
() => ({
title: t("message.chatHistory.title"),
subtitle: source?.title ? (
<Text style={styles.description} numberOfLines={1}>
{source.title}
</Text>
) : undefined,
}),
[source?.title, t],
);
const footer = useMemo(
() =>
source ? (
<Button variant="outline" size="sm" leftIcon={ArrowUpRight} onPress={openSource}>
{t("message.chatHistory.openSource")}
</Button>
) : undefined,
[source, openSource, t],
);
const history = useMemo(() => extractChatHistoryBody(attachment.text), [attachment.text]);
const content = getAgentAttachmentPillContent(attachment, t);
const label = (
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
);
const openLabel = t("message.chatHistory.open");
return (
<>
{onRemove ? (
<AttachmentPill
testID={testID}
onOpen={open}
onRemove={onRemove}
disabled={disabled}
openAccessibilityLabel={openLabel}
removeAccessibilityLabel={t("message.chatHistory.remove")}
>
{label}
</AttachmentPill>
) : (
<AttachmentFrame testID={testID} onPress={open} accessibilityLabel={openLabel}>
{label}
</AttachmentFrame>
)}
<AdaptiveModalSheet
visible={visible}
onClose={close}
header={header}
snapPoints={FULL_SHEET_SNAP_POINTS}
desktopMaxWidth={720}
desktopHeight="75%"
testID="chat-history-preview"
contentStyle={styles.content}
footer={footer}
>
<Text style={styles.description}>{t("message.chatHistory.description")}</Text>
<Text selectable testID="chat-history-content" style={styles.history}>
{history}
</Text>
</AdaptiveModalSheet>
</>
);
}
const styles = StyleSheet.create((theme) => ({
content: { gap: theme.spacing[4] },
description: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
history: {
fontSize: theme.fontSize.content,
lineHeight: theme.fontSize.content * 1.6,
color: theme.colors.foreground,
},
}));
export function restoreChatHistoryAttachment(scopeKey: string, attachment: HistoryAttachment) {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey,
attachment: {
kind: "chat_history",
id: generateMessageId(),
attachment,
source: attachment.chatHistorySource,
},
});
}
+3 -2
View File
@@ -1,6 +1,5 @@
import type {
AgentAttachment,
ChatHistorySource,
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
@@ -83,7 +82,9 @@ export interface ChatHistoryContextAttachment {
kind: "chat_history";
id: string;
attachment: Extract<AgentAttachment, { type: "text" }>;
source?: ChatHistorySource & {
source: {
serverId: string;
agentId: string;
boundaryMessageId?: string | null;
boundaryCursor?: { epoch: string; seq: number } | null;
itemCount?: number;
@@ -21,29 +21,6 @@ function contextAttachment(
}
describe("workspace attachment utilities", () => {
it("submits chat history with its source host and agent", () => {
const source = { serverId: "original-host", agentId: "original-agent", title: "Original" };
expect(
workspaceAttachmentToSubmitAttachment({
kind: "chat_history",
id: "history-draft",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Earlier context",
},
source,
}),
).toEqual({
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Earlier context",
chatHistorySource: source,
});
});
it("treats pull request context as a workspace attachment", () => {
expect(isWorkspaceAttachment(contextAttachment())).toBe(true);
});
@@ -62,12 +62,7 @@ export function workspaceAttachmentToSubmitAttachment(
};
}
if (attachment.kind === "chat_history") {
if (!attachment.source) return attachment.attachment;
const { serverId, agentId, title } = attachment.source;
return {
...attachment.attachment,
chatHistorySource: { serverId, agentId, ...(title ? { title } : {}) },
};
return attachment.attachment;
}
return attachment.kind === "review" ? attachment.attachment : null;
}
@@ -178,19 +178,6 @@ export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope):
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
}
export function useAgentAttachmentScopeKey(input: { serverId: string; agentId: string }): string {
const { serverId, agentId } = input;
return useMemo(
() =>
[
"agent-attachments",
`server=${encodeScopePart(serverId)}`,
`agent=${encodeScopePart(agentId)}`,
].join(":"),
[serverId, agentId],
);
}
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
return useMemo(
+24 -79
View File
@@ -17,7 +17,6 @@ import {
Text,
useWindowDimensions,
View,
type LayoutChangeEvent,
} from "react-native";
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
@@ -41,17 +40,6 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarFooterOverflowMenu } from "@/components/sidebar/sidebar-footer-overflow-menu";
import {
resolveSidebarFooterLayout,
SIDEBAR_FOOTER_ACTIONS,
type SidebarFooterAction,
} from "@/components/sidebar-footer-layout";
import {
controlSizingForLayout,
mapLayout,
TOOLBAR_CONTROL_SIZES,
} from "@/components/ui/control-geometry";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
@@ -67,7 +55,6 @@ import {
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import { SPACING } from "@/styles/theme";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
@@ -92,9 +79,6 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
/** The footer icon line's inset; the overflow layout counts it as space the buttons can't use. */
const FOOTER_ROW_PADDING = SPACING[2];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
interface SidebarSharedProps {
@@ -426,72 +410,35 @@ function SidebarFooter({
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const isCompactLayout = useIsCompactFormFactor();
const [rowWidth, setRowWidth] = useState(0);
const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
setRowWidth(event.nativeEvent.layout.width);
}, []);
const { inline, overflow } = useMemo(
() =>
resolveSidebarFooterLayout({
rowWidth,
controlSize: TOOLBAR_CONTROL_SIZES[controlSizingForLayout(isCompactLayout)],
rowPadding: FOOTER_ROW_PADDING,
actions: SIDEBAR_FOOTER_ACTIONS,
}),
[rowWidth, isCompactLayout],
);
const isInline = (action: SidebarFooterAction) => inline.includes(action);
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. Actions that
// don't fit move behind a caret before Settings.
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
return (
<UsageSidebarRoot>
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View
style={styles.sidebarFooter}
onLayout={handleRowLayout}
testID="sidebar-footer-bottom-line"
>
{isInline("add-project") ? (
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
/>
) : null}
{isInline("usage") ? <SidebarUsageIcon label={labels.usage} /> : null}
{isInline("hosts") ? (
<SidebarHostPicker
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
) : null}
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
/>
<SidebarUsageIcon label={labels.usage} />
<SidebarHostPicker
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<View style={styles.footerSpacer} />
{isInline("help") ? <SidebarHelpMenu /> : null}
{overflow.length > 0 ? (
<SidebarFooterOverflowMenu
actions={overflow}
labels={labels}
onAddProject={handleOpenProject}
onSettings={handleSettings}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
) : null}
{isInline("settings") ? (
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
/>
) : null}
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
/>
</View>
</View>
</UsageSidebarRoot>
@@ -990,12 +937,10 @@ const styles = StyleSheet.create((theme) => ({
borderTopColor: theme.colors.border,
},
// Buttons sit edge to edge; their own inset spaces the glyphs.
// As tall as its buttons from the first frame, which places none until the row has measured.
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2),
paddingHorizontal: FOOTER_ROW_PADDING,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
},
// Pushes Help and Settings to the end of the icon line.
+8 -29
View File
@@ -1,4 +1,3 @@
import { ChatHistoryAttachment } from "@/attachments/chat-history";
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { MessagePreview, MessageSender } from "@/components/user-message";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -34,10 +33,7 @@ import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type {
AgentAttachment,
ChatHistoryAttachment as ChatHistorySnapshot,
} from "@getpaseo/protocol/messages";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Check,
@@ -423,12 +419,7 @@ export const UserMessage = memo(function UserMessage({
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const chatHistory = attachments.find(
(attachment): attachment is ChatHistorySnapshot =>
attachment.type === "text" && attachment.contextKind === "chat_history",
);
const showTrailingRow =
!isPending && (hasText || Boolean(chatHistory)) && (isCompact || isNative || isHovered);
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
const formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
@@ -437,12 +428,9 @@ export const UserMessage = memo(function UserMessage({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const getMessageContent = useCallback(
() => message || chatHistory?.text || "",
[message, chatHistory],
);
const getMessageContent = useCallback(() => message, [message]);
const handleRewind = useCallback(
(input: { mode: RewindMode; rewoundText: string; chatHistory?: ChatHistorySnapshot }) => {
(input: { mode: RewindMode; rewoundText: string }) => {
return rewindMutation.rewindAgent(input);
},
[rewindMutation],
@@ -508,19 +496,11 @@ export const UserMessage = memo(function UserMessage({
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => {
const key = `${attachment.type}:${"number" in attachment ? attachment.number : index}`;
if (attachment.type === "text" && attachment.contextKind === "chat_history") {
return (
<ChatHistoryAttachment
key={key}
attachment={attachment}
testID="message-chat-history-attachment"
/>
);
}
const content = getAgentAttachmentPillContent(attachment, t);
return (
<AttachmentFrame key={key}>
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentLabel
icon={content.icon}
title={content.title}
@@ -538,7 +518,7 @@ export const UserMessage = memo(function UserMessage({
) : null}
</MessagePreview>
</View>
{hasText || chatHistory ? (
{hasText ? (
<View
style={trailingRowStyle}
pointerEvents={showTrailingRow ? "auto" : "none"}
@@ -552,7 +532,6 @@ export const UserMessage = memo(function UserMessage({
capabilities={capabilities}
isPending={rewindMutation.isPending}
rewoundText={message}
chatHistory={chatHistory}
onRewind={handleRewind}
/>
) : null}
@@ -1,45 +1,27 @@
import { describe, expect, test } from "vitest";
import { restoreComposerPromptIfEmpty } from "./composer-restore";
import { restoreComposerTextIfEmpty } from "./composer-restore";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
describe("restoreComposerPromptIfEmpty", () => {
describe("restoreComposerTextIfEmpty", () => {
test("restores the rewound message when the composer is empty", () => {
expect(
restoreComposerPromptIfEmpty({
restoreComposerTextIfEmpty({
currentText: "",
rewoundPrompt: { text: "message before rewind" },
rewoundText: "message before rewind",
}),
).toEqual({ text: "message before rewind" });
).toBe("message before rewind");
});
test("preserves an existing composer draft", () => {
expect(
restoreComposerPromptIfEmpty({
restoreComposerTextIfEmpty({
currentText: "keep this draft",
rewoundPrompt: { text: "message before rewind" },
rewoundText: "message before rewind",
}),
).toEqual({ text: "keep this draft" });
).toBe("keep this draft");
});
});
test.each(["", "Continue here"])("restores inherited history with rewound prompt %s", (text) => {
const chatHistory = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Earlier context",
} as const;
expect(
restoreComposerPromptIfEmpty({ currentText: "", rewoundPrompt: { text, chatHistory } }),
).toEqual({ text, chatHistory });
expect(
restoreComposerPromptIfEmpty({
currentText: "Existing draft",
rewoundPrompt: { text, chatHistory },
}),
).toEqual({ text: "Existing draft" });
});
describe("shouldRestoreComposerForRewindMode", () => {
test("restores only conversation-mutating rewind modes", () => {
expect(shouldRestoreComposerForRewindMode("conversation")).toBe(true);
@@ -1,14 +1,11 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { restoreChatHistoryAttachment } from "@/attachments/chat-history";
import type { ComposerTextSource } from "@/composer/text-source";
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
interface RewindComposerRestoreContextValue {
completeRewind: (prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => void;
completeRewind: (text: string) => void;
}
interface RewindComposerRestoreProviderProps {
attachmentScopeKey: string;
textSource: ComposerTextSource;
setText: (text: string) => void;
onRewindComplete: () => void;
@@ -17,34 +14,35 @@ interface RewindComposerRestoreProviderProps {
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
export function restoreComposerPromptIfEmpty(input: {
export function restoreComposerTextIfEmpty(input: {
currentText: string;
rewoundPrompt: { text: string; chatHistory?: ChatHistoryAttachment };
}): { text: string; chatHistory?: ChatHistoryAttachment } {
if (input.currentText.length > 0) return { text: input.currentText };
return input.rewoundPrompt;
rewoundText: string;
}): string {
if (input.currentText.length > 0) {
return input.currentText;
}
return input.rewoundText;
}
export function RewindComposerRestoreProvider({
attachmentScopeKey,
textSource,
setText,
onRewindComplete,
children,
}: RewindComposerRestoreProviderProps) {
const completeRewind = useCallback(
(prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => {
(rewoundText: string) => {
const currentText = textSource.getSnapshot();
const restored = restoreComposerPromptIfEmpty({ currentText, rewoundPrompt: prompt });
if (restored.chatHistory) {
restoreChatHistoryAttachment(attachmentScopeKey, restored.chatHistory);
}
if (restored.text !== currentText) {
setText(restored.text);
const nextText = restoreComposerTextIfEmpty({
currentText: currentText,
rewoundText,
});
if (nextText !== currentText) {
setText(nextText);
}
onRewindComplete();
},
[attachmentScopeKey, onRewindComplete, setText, textSource],
[onRewindComplete, setText, textSource],
);
const value = useMemo(() => ({ completeRewind }), [completeRewind]);
@@ -1,4 +1,3 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -20,12 +19,7 @@ export type { RewindMode };
interface RewindMenuProps {
capabilities: AgentCapabilityFlags;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
onRewind: (input: {
mode: RewindMode;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
}) => Promise<void> | void;
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
isPending?: boolean;
testID?: string;
}
@@ -44,7 +38,6 @@ function getIcon(mode: RewindMode, color: string): ReactElement {
export const RewindMenu = memo(function RewindMenu({
capabilities,
rewoundText,
chatHistory,
onRewind,
isPending: isPendingProp = false,
testID = "rewind-menu",
@@ -77,7 +70,7 @@ export const RewindMenu = memo(function RewindMenu({
if (isLocked) return;
setPendingMode(mode);
try {
await onRewind({ mode, rewoundText, chatHistory });
await onRewind({ mode, rewoundText });
} catch {
// useRewindAgentMutation owns the toast; the menu only owns flow state.
} finally {
@@ -85,7 +78,7 @@ export const RewindMenu = memo(function RewindMenu({
setIsOpen(false);
}
},
[isLocked, onRewind, rewoundText, chatHistory],
[isLocked, onRewind, rewoundText],
);
const triggerStyle = useCallback(
@@ -1,4 +1,3 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -21,7 +20,6 @@ interface UseRewindAgentMutationInput {
interface RewindAgentInput {
mode: RewindMode;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
}
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
@@ -54,10 +52,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
return;
}
composerRestore?.completeRewind({
text: variables.rewoundText,
chatHistory: variables.chatHistory,
});
composerRestore?.completeRewind(variables.rewoundText);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
@@ -1,89 +0,0 @@
import { describe, expect, it } from "vitest";
import {
resolveSidebarFooterLayout,
SIDEBAR_FOOTER_ACTIONS,
} from "@/components/sidebar-footer-layout";
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
const TOUCH = 44;
const POINTER = 28;
const ROW_PADDING = 8;
function layout(rowWidth: number, controlSize: number) {
return resolveSidebarFooterLayout({
rowWidth,
controlSize,
rowPadding: ROW_PADDING,
actions: SIDEBAR_FOOTER_ACTIONS,
});
}
describe("sidebar footer layout", () => {
it("keeps every action inline at the default width with either sizing", () => {
for (const controlSize of [TOUCH, POINTER]) {
expect(layout(DEFAULT_SIDEBAR_WIDTH, controlSize)).toEqual({
inline: ["add-project", "usage", "hosts", "help", "settings"],
overflow: [],
});
}
});
it("keeps every action inline at the minimum width with pointer sizing", () => {
expect(layout(MIN_SIDEBAR_WIDTH, POINTER).overflow).toEqual([]);
});
// The iPhone Duo report: the folded sidebar column is about the minimum width, and touch
// sizing makes five 44pt buttons plus padding 236pt wide.
it("moves Help and Usage behind the caret at the minimum width with touch sizing", () => {
expect(layout(MIN_SIDEBAR_WIDTH, TOUCH)).toEqual({
inline: ["add-project", "hosts", "settings"],
overflow: ["usage", "help"],
});
});
it("keeps Settings and Add project inline longest", () => {
// Three slots: two actions and the caret.
expect(layout(3 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
inline: ["add-project", "settings"],
overflow: ["usage", "hosts", "help"],
});
// Two slots: Settings and the caret.
expect(layout(2 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
inline: ["settings"],
overflow: ["add-project", "usage", "hosts", "help"],
});
});
it("puts everything behind the caret when only the caret fits", () => {
expect(layout(TOUCH + 2 * ROW_PADDING - 10, TOUCH)).toEqual({
inline: [],
overflow: ["add-project", "usage", "hosts", "help", "settings"],
});
});
it("fits all five actions exactly at their combined width", () => {
const exact = 5 * TOUCH + 2 * ROW_PADDING;
expect(layout(exact, TOUCH).overflow).toEqual([]);
// Browser layout rounds a little below the exact width.
expect(layout(exact - 0.4, TOUCH).overflow).toEqual([]);
expect(layout(exact - 1, TOUCH).overflow).toEqual(["usage", "help"]);
});
it("only places the actions it is given", () => {
expect(
resolveSidebarFooterLayout({
rowWidth: 3 * TOUCH + 2 * ROW_PADDING,
controlSize: TOUCH,
rowPadding: ROW_PADDING,
actions: ["add-project", "hosts", "settings"],
}),
).toEqual({ inline: ["add-project", "hosts", "settings"], overflow: [] });
});
// Laying out against a guessed width would paint a clipped row for a frame on narrow sidebars.
it("places nothing before the row has been measured", () => {
for (const controlSize of [TOUCH, POINTER]) {
expect(layout(0, controlSize)).toEqual({ inline: [], overflow: [] });
}
});
});
@@ -1,62 +0,0 @@
/** The sidebar footer's icon line, in row order. */
export const SIDEBAR_FOOTER_ACTIONS = [
"add-project",
"usage",
"hosts",
"help",
"settings",
] as const;
export type SidebarFooterAction = (typeof SIDEBAR_FOOTER_ACTIONS)[number];
/** The order actions keep their place in a row too narrow for all of them. */
const KEEP_ORDER: readonly SidebarFooterAction[] = [
"settings",
"add-project",
"hosts",
"usage",
"help",
];
/** Measured widths land a hair under the exact sum of their parts. */
const MEASUREMENT_TOLERANCE = 0.5;
export interface SidebarFooterLayout {
/** Actions drawn in the row, in row order. */
inline: SidebarFooterAction[];
/** Actions behind the overflow caret, in row order. Empty when everything fits. */
overflow: SidebarFooterAction[];
}
/**
* Splits the footer's actions between the row and the overflow menu. Buttons keep their full size;
* when they don't all fit, the caret takes one slot and the actions that lose their place move
* behind it, Help first and Settings last. Nothing is placed until the row has a measured width.
*/
export function resolveSidebarFooterLayout(input: {
rowWidth: number;
controlSize: number;
rowPadding: number;
actions: readonly SidebarFooterAction[];
}): SidebarFooterLayout {
const { rowWidth, controlSize, rowPadding, actions } = input;
// An unmeasured row stays empty: any guess would paint a clipped row for a frame on a narrow
// sidebar before the real width arrives.
if (rowWidth <= 0) {
return { inline: [], overflow: [] };
}
const slots = Math.floor(
(rowWidth - rowPadding * 2 + MEASUREMENT_TOLERANCE) / Math.max(controlSize, 1),
);
if (slots >= actions.length) {
return { inline: [...actions], overflow: [] };
}
const inlineCount = Math.max(slots - 1, 0);
const kept = new Set(
KEEP_ORDER.filter((action) => actions.includes(action)).slice(0, inlineCount),
);
return {
inline: actions.filter((action) => kept.has(action)),
overflow: actions.filter((action) => !kept.has(action)),
};
}
@@ -1,69 +0,0 @@
import type { ReactElement } from "react";
import { Text, View } from "react-native";
import type { LucideIcon } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { density } from "@/styles/density";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
/**
* A footer icon that opens a menu. Sized and drawn like the footer's other icon buttons; the
* tooltip stays out of the way while the menu is open.
*/
export function SidebarFooterMenuTrigger({
icon,
label,
menuOpen,
testID,
}: {
icon: LucideIcon;
label: string;
menuOpen: boolean;
testID: string;
}): ReactElement {
return (
<Tooltip delayDuration={300} enabledOnDesktop={!menuOpen}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
>
{({ hovered, pressed, open }) => (
<>
<SidebarFooterIconHighlight visible={pressed || open} />
<DensityIcon
icon={icon}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
}));
@@ -1,245 +0,0 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronUp,
CircleGauge,
CircleHelp,
FolderPlus,
Plus,
Server,
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import type { SidebarFooterAction } from "@/components/sidebar-footer-layout";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSubTrigger,
type MenuPageDefinition,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useHosts } from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { orderHostsLocalFirst } from "@/types/host-connection";
import { useOpenSidebarUsage } from "@/usage";
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
import { SidebarHelpMenuItems } from "./sidebar-help-menu";
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedCircleGauge = withUnistyles(CircleGauge);
const ThemedServer = withUnistyles(Server);
const ThemedCircleHelp = withUnistyles(CircleHelp);
const ThemedSettings = withUnistyles(Settings);
const ThemedPlus = withUnistyles(Plus);
const addProjectLeading = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const usageLeading = <ThemedCircleGauge size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const hostsLeading = <ThemedServer size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const helpLeading = <ThemedCircleHelp size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const settingsLeading = <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const addHostLeading = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const HOSTS_PAGE_ID = "hosts";
const HELP_PAGE_ID = "help";
export interface SidebarFooterOverflowMenuProps {
/** The footer actions that did not fit in the row, in row order. */
actions: readonly SidebarFooterAction[];
labels: { addProject: string; hosts: string; settings: string; usage: string };
onAddProject: () => void;
onSettings: () => void;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}
/**
* The caret at the end of a footer row too narrow for every action. It lists the actions that
* lost their place, with the same test IDs they carry in the row; Hosts and Help, menus of their
* own in the row, open as pages of this menu.
*/
export function SidebarFooterOverflowMenu({
actions,
labels,
onAddProject,
onSettings,
onAddHost,
onOpenHostSettings,
}: SidebarFooterOverflowMenuProps): ReactElement {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const openUsage = useOpenSidebarUsage();
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const helpLabel = t("sidebar.help.trigger");
const addProjectTrailing = useMemo(
() => (newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null),
[newAgentKeys],
);
const settingsTrailing = useMemo(
() => (settingsKeys ? <Shortcut chord={settingsKeys} /> : null),
[settingsKeys],
);
const pages = useMemo((): MenuPageDefinition[] => {
const result: MenuPageDefinition[] = [];
if (actions.includes("hosts")) {
result.push({
id: HOSTS_PAGE_ID,
title: labels.hosts,
content: <HostsPage onAddHost={onAddHost} onOpenHostSettings={onOpenHostSettings} />,
});
}
if (actions.includes("help")) {
result.push({ id: HELP_PAGE_ID, title: helpLabel, content: <SidebarHelpMenuItems /> });
}
return result;
}, [actions, helpLabel, labels.hosts, onAddHost, onOpenHostSettings]);
function renderAction(action: SidebarFooterAction): ReactElement {
switch (action) {
case "add-project":
return (
<DropdownMenuItem
key={action}
testID="sidebar-add-project"
leading={addProjectLeading}
trailing={addProjectTrailing}
onSelect={onAddProject}
>
{labels.addProject}
</DropdownMenuItem>
);
case "usage":
return (
<DropdownMenuItem
key={action}
testID="sidebar-usage-icon"
leading={usageLeading}
onSelect={openUsage}
>
{labels.usage}
</DropdownMenuItem>
);
case "hosts":
return (
<DropdownMenuSubTrigger
key={action}
id={HOSTS_PAGE_ID}
testID="sidebar-hosts-trigger"
leading={hostsLeading}
>
{labels.hosts}
</DropdownMenuSubTrigger>
);
case "help":
return (
<DropdownMenuSubTrigger
key={action}
id={HELP_PAGE_ID}
testID="sidebar-help"
leading={helpLeading}
>
{helpLabel}
</DropdownMenuSubTrigger>
);
case "settings":
return (
<DropdownMenuItem
key={action}
testID="sidebar-settings"
leading={settingsLeading}
trailing={settingsTrailing}
onSelect={onSettings}
>
{labels.settings}
</DropdownMenuItem>
);
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<SidebarFooterMenuTrigger
icon={ChevronUp}
label={t("sidebar.actions.moreActions")}
menuOpen={open}
testID="sidebar-footer-overflow"
/>
<DropdownMenuContent
side="top"
align="end"
offset={8}
width={240}
pages={pages}
scrollable
testID="sidebar-footer-overflow-menu"
>
{actions.map(renderAction)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/** The host picker's choices as a menu page: each host opens its settings, then Add host. */
function HostsPage({
onAddHost,
onOpenHostSettings,
}: {
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}): ReactElement {
const { t } = useTranslation();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
return (
<>
{orderedHosts.map((host) => (
<HostRow
key={host.serverId}
serverId={host.serverId}
label={host.label}
onOpenHostSettings={onOpenHostSettings}
/>
))}
{orderedHosts.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem testID="sidebar-host-add" leading={addHostLeading} onSelect={onAddHost}>
{t("settings.addHost")}
</DropdownMenuItem>
</>
);
}
function HostRow({
serverId,
label,
onOpenHostSettings,
}: {
serverId: string;
label: string;
onOpenHostSettings: (serverId: string) => void;
}): ReactElement {
const handleSelect = useCallback(
() => onOpenHostSettings(serverId),
[onOpenHostSettings, serverId],
);
const leading = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
return (
<DropdownMenuItem
testID={`sidebar-host-row-${serverId}`}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { View } from "react-native";
import { Text, View } from "react-native";
import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -12,13 +12,17 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -73,32 +77,12 @@ function HostVersionHint({ host }: { host: HostProfile }) {
);
}
/** The Help menu's trigger, and the menu it opens. */
export function SidebarHelpMenu() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<SidebarFooterMenuTrigger
icon={CircleHelp}
label={t("sidebar.help.trigger")}
menuOpen={open}
testID="sidebar-help"
/>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<SidebarHelpMenuItems />
</DropdownMenuContent>
</DropdownMenu>
);
}
/** The Help menu's rows, shared by the Help menu and the footer's overflow page for it. */
export function SidebarHelpMenuItems() {
const { t } = useTranslation();
const shortcutsAvailable = useHardwareKeyboardPresent();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
const [open, setOpen] = useState(false);
const version = formatVersionWithPrefix(resolveAppVersion());
const hosts = useHosts();
@@ -115,65 +99,105 @@ export function SidebarHelpMenuItems() {
}, []);
return (
<>
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered, pressed, open: menuOpen }) => (
<>
<SidebarFooterIconHighlight visible={pressed || menuOpen} />
<DensityIcon
icon={CircleHelp}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("sidebar.help.trigger")}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
<DropdownMenuItem
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
onSelect={openKeyboardShortcuts}
>
{t("sidebar.help.shortcuts")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
onSelect={openKeyboardShortcuts}
testID="sidebar-help-changelog"
leading={changelogLeadingIcon}
onSelect={openChangelog}
>
{t("sidebar.help.shortcuts")}
{t("sidebar.help.whatsNew")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="sidebar-help-changelog"
leading={changelogLeadingIcon}
onSelect={openChangelog}
>
{t("sidebar.help.whatsNew")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-diagnostics"
leading={diagnosticLeadingIcon}
onSelect={openAppDiagnostic}
>
{t("sidebar.help.diagnostics")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
<DropdownMenuItem
testID="sidebar-help-discord"
leading={discordLeadingIcon}
onSelect={openDiscord}
>
{t("sidebar.help.discord")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-github"
leading={githubLeadingIcon}
onSelect={openGitHubIssue}
>
{t("sidebar.help.github")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<View style={styles.versionList}>
<DropdownMenuHint
style={styles.versionHint}
trailing={version}
testID="sidebar-help-version"
<DropdownMenuItem
testID="sidebar-help-diagnostics"
leading={diagnosticLeadingIcon}
onSelect={openAppDiagnostic}
>
{t("sidebar.help.appName")}
</DropdownMenuHint>
{hosts.map((host) => (
<HostVersionHint key={host.serverId} host={host} />
))}
</View>
</>
{t("sidebar.help.diagnostics")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
<DropdownMenuItem
testID="sidebar-help-discord"
leading={discordLeadingIcon}
onSelect={openDiscord}
>
{t("sidebar.help.discord")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-github"
leading={githubLeadingIcon}
onSelect={openGitHubIssue}
>
{t("sidebar.help.github")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<View style={styles.versionList}>
<DropdownMenuHint
style={styles.versionHint}
trailing={version}
testID="sidebar-help-version"
>
{t("sidebar.help.appName")}
</DropdownMenuHint>
{hosts.map((host) => (
<HostVersionHint key={host.serverId} host={host} />
))}
</View>
</DropdownMenuContent>
</DropdownMenu>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
versionList: {
gap: theme.spacing[1],
paddingVertical: theme.spacing[2],
+1 -24
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor";
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
@@ -97,26 +97,3 @@ describe("getTransformOrigin", () => {
expect(getTransformOrigin(placement, alignment)).toEqual(expected);
});
});
describe("fitSurfaceWidth", () => {
it("leaves a surface that fits the display alone, capped at the display", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180 }, 1000)).toEqual({
width: 240,
minWidth: 180,
maxWidth: 984,
});
});
it("shrinks a surface wider than a narrow display to the space inside its edge padding", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180, maxWidth: 320 }, 220)).toEqual({
width: 204,
minWidth: 180,
maxWidth: 204,
});
expect(fitSurfaceWidth({ minWidth: 180 }, 150)).toEqual({
width: undefined,
minWidth: 134,
maxWidth: 134,
});
});
});
-21
View File
@@ -120,27 +120,6 @@ function clampToDisplayArea(input: {
};
}
export interface SurfaceWidth {
width?: number;
minWidth?: number;
maxWidth?: number;
}
/**
* Caps a surface's requested width to the display. Position clamping can move a surface but not
* shrink it, so a menu wider than a narrow screen would otherwise hang off the far edge.
*/
export function fitSurfaceWidth(requested: SurfaceWidth, displayWidth: number): SurfaceWidth {
const available = Math.max(displayWidth - EDGE_PADDING * 2, 0);
const cap = (value: number | undefined) =>
typeof value === "number" ? Math.min(value, available) : undefined;
return {
width: cap(requested.width),
minWidth: cap(requested.minWidth),
maxWidth: cap(requested.maxWidth) ?? available,
};
}
export function computePosition({
triggerRect,
contentSize,
@@ -14,8 +14,6 @@ import {
getControlInteractionPhase,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_SIZE,
TOOLBAR_DISC_SIZES,
TOUCH_TARGET,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -92,11 +90,6 @@ describe("control sizing gate", () => {
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
// A filled toolbar control paints a 36pt disc inside its 44pt touch frame, and fills its
// 28pt pointer frame exactly, so desktop is unchanged. Its glyph is the `md` glyph.
expect(TOOLBAR_DISC_SIZES).toEqual({ touch: 36, pointer: TOOLBAR_CONTROL_SIZES.pointer });
expect(TOOLBAR_DISC_SIZE).toEqual({ xs: 36, md: 28 });
expect(iconSizeFor("md")).toEqual({ touch: 20, pointer: 16 });
for (const size of ["sm", "md"] as const) {
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
controlHeightForLayout(size, false),
@@ -93,14 +93,6 @@ export const ICON_BUTTON_SIZES = {
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
/**
* The painted disc of a filled toolbar control, such as the composer's send and stop. The
* control keeps the full toolbar hit frame and paints this smaller disc centered inside it, so
* touch-sized it does not outweigh its unpainted neighbours. Pointer-sized it fills the frame.
*/
export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES);
/**
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
* text, so each step is larger.
@@ -29,7 +29,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
measureElement,
type Alignment,
@@ -284,7 +283,11 @@ export function AnchoredSurface({
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
resolvedWidthStyle,
{
@@ -1,4 +1,3 @@
import { ChatHistoryAttachment } from "@/attachments/chat-history";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import type {
@@ -256,28 +255,12 @@ function WorkspaceAttachmentPill({
}: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
const content = getWorkspaceAttachmentPillContent(attachment, t);
const chatHistory = useMemo(() => {
if (attachment.kind !== "chat_history") return null;
if (!attachment.source) return attachment.attachment;
const { serverId, agentId, title } = attachment.source;
return { ...attachment.attachment, chatHistorySource: { serverId, agentId, title } };
}, [attachment]);
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
if (chatHistory) {
return (
<ChatHistoryAttachment
attachment={chatHistory}
onRemove={handleRemove}
disabled={disabled}
testID={getPillTestID(attachment)}
/>
);
}
return (
<AttachmentPill
testID={getPillTestID(attachment)}
@@ -242,10 +242,10 @@ describe("useDraftAgentCreateFlow", () => {
expect(createRequest).toHaveBeenCalledWith({
attempt: expect.objectContaining({
text: "",
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
attachments: [attachment.attachment],
}),
text: "",
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
attachments: [attachment.attachment],
cwd: "/repo",
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
+2 -10
View File
@@ -35,7 +35,6 @@ import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_DISC_SIZE,
} from "@/components/ui/control-geometry";
import { usePanelStore } from "@/stores/panel-store";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
@@ -1118,7 +1117,7 @@ function ComposerCancelButton({
accessibilityRole="button"
style={cancelButtonStyle}
>
<View style={styles.cancelButtonDisc}>{icon}</View>
{icon}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -2596,21 +2595,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
width: "100%",
gap: theme.spacing[3],
},
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
cancelButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
cancelButtonDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[600],
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
rightControls: {
flexDirection: "row",
+21 -38
View File
@@ -33,7 +33,6 @@ import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_DISC_SIZE,
} from "@/components/ui/control-geometry";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
@@ -350,11 +349,7 @@ function VoiceButtonIcon({
buttonIconSize: number;
}) {
if (isDictating) {
return (
<View style={styles.voiceButtonRecordingDisc}>
<Square size={buttonIconSize} color="white" fill="white" />
</View>
);
return <Square size={buttonIconSize} color="white" fill="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
if (isMutedRealtime) {
@@ -842,14 +837,12 @@ function SendButtonTooltip({
testID={submitButtonTestID}
style={sendButtonCombinedStyle}
>
<View style={submitLabel ? styles.sendButtonDiscLabeled : styles.sendButtonDisc}>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
</View>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
@@ -1932,6 +1925,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
styles.voiceButton,
Boolean(hovered) && !isDictating && styles.iconButtonHovered,
!isDictationStartEnabled && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
],
[isDictating, isDictationStartEnabled],
);
@@ -1980,8 +1974,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
allowEmpty: allowEmptySubmit,
});
const sendButtonCombinedStyle = useMemo(
() => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled],
[isPresentedSendDisabled],
() => [
styles.sendButton,
submitLabel ? styles.sendButtonLabeled : undefined,
isPresentedSendDisabled && styles.buttonDisabled,
],
[isPresentedSendDisabled, submitLabel],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
@@ -2418,38 +2416,23 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecordingDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
voiceButtonRecording: {
backgroundColor: theme.colors.destructive,
alignItems: "center",
justifyContent: "center",
},
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
sendButton: {
minWidth: TOOLBAR_CONTROL_HEIGHT,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
sendButtonDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
sendButtonDiscLabeled: {
minWidth: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
sendButtonLabeled: {
width: "auto",
minWidth: TOOLBAR_CONTROL_HEIGHT,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
sendButtonLabel: {
fontSize: theme.fontSize.base,
+53 -8
View File
@@ -1,4 +1,4 @@
import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
@@ -144,10 +144,21 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
["status", "providers_snapshot_update"],
() => {},
),
observeCheckoutDiff(cwd, compare) {
observeDiff(input) {
const cwd = input.cwd;
let mode: "uncommitted" | "base" | "all" = "uncommitted";
if (input.head.kind === "rev") mode = "base";
else if (input.mergeBase) mode = "all";
const compare = {
mode,
...(input.base.kind === "rev" && input.base.rev !== "HEAD"
? { baseRef: input.base.rev }
: {}),
ignoreWhitespace: input.ignoreWhitespace,
};
const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`;
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
const handle = observe(
const handle = observe<SubscribeCheckoutDiffResponseMessage["payload"]>(
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
["checkout_diff_update", "subscribe_checkout_diff_response"],
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
@@ -155,9 +166,43 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
"subscriptionId" in message.payload &&
message.payload.subscriptionId === subscriptionId,
);
if (!config.rejectCheckoutDiffSubscribe) return handle;
return {
const modern: OwnedSubscription<DiffState> = {
...handle,
ready: Promise.resolve({
phase: "ready",
revision: 1,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files: [],
diffTooLarge: false,
subscriptionId,
}),
subscribe: (observer) =>
handle.subscribe({
snapshot: (payload) =>
observer.snapshot({
phase: "ready",
revision: 1,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files: payload.files,
diffTooLarge: payload.diffTooLarge === true,
subscriptionId,
}),
update: (message) => {
if (message.type === "checkout_diff_update")
observer.snapshot({
phase: "ready",
revision: 2,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files: message.payload.files,
diffTooLarge: message.payload.diffTooLarge === true,
subscriptionId,
});
},
}),
};
if (!config.rejectCheckoutDiffSubscribe) return modern;
return {
...modern,
subscribe: (observer) => {
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
return () => {};
@@ -328,7 +373,7 @@ describe("server data push router", () => {
const first = publish(files, "first");
const same = publish(structuredClone(files), "reopen");
expect(same.files).toBe(first.files);
expect(same.requestId).toBe("reopen");
expect(same.requestId).toBe("diff:1:ready");
const changed = structuredClone(files);
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
const next = publish(changed, "edit");
@@ -430,7 +475,7 @@ describe("server data push router", () => {
files: [],
error: null,
diffTooLarge: true,
requestId: "diff-1",
requestId: "diff:1:ready",
});
fake.emit({
@@ -449,7 +494,7 @@ describe("server data push router", () => {
files: [],
error: null,
diffTooLarge: true,
requestId: `subscription:${serverSubscriptionId}`,
requestId: "diff:2:ready",
});
unsubscribeObserver();
+27 -30
View File
@@ -1,3 +1,5 @@
import { checkoutDiffSnapshot, type CheckoutDiffSnapshot } from "@/git/diff-snapshot";
import type { DiffState } from "@getpaseo/client";
import type { OwnedSubscription } from "@getpaseo/client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
@@ -23,14 +25,9 @@ type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage,
{ type: "providers_snapshot_update" }
>;
type SubscribeCheckoutDiffResponseMessage = Extract<
SessionOutboundMessage,
{ type: "subscribe_checkout_diff_response" }
>;
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
type ListTerminalsPayload = ListTerminalsResponse["payload"];
interface CheckoutDiffCompare {
@@ -49,7 +46,7 @@ interface CheckoutDiffRoute {
}
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
subscription: OwnedSubscription<DiffState>;
}
interface WorkspaceTerminalsRoute {
@@ -75,7 +72,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
interface ServerDataPushClient {
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"];
observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"];
observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"];
observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"];
}
@@ -339,34 +336,34 @@ function reconcileCheckoutDiffSubscriptions(input: {
}
for (const [key, desired] of input.desired) {
if (input.active.has(key)) continue;
const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare);
const { mode, baseRef, ignoreWhitespace } = desired.compare;
const revision = mode === "uncommitted" ? "HEAD" : baseRef;
if (!revision) continue;
const subscription = input.client.observeDiff({
cwd: desired.cwd,
base: { kind: "rev", rev: revision },
head: mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" },
mergeBase: mode !== "uncommitted",
ignoreWhitespace,
});
const registration = { ...desired, subscription };
input.active.set(key, registration);
const apply = (
payload: Omit<CheckoutDiffResponsePayload, "requestId"> & { requestId?: string },
) => {
if (input.active.get(key) !== registration) return;
const { subscriptionId, ...snapshot } = payload;
setCheckoutDiffPayload({
activeCheckoutDiffSubscriptions: input.active,
queryClient: input.queryClient,
serverId: input.serverId,
subscriptionId: key,
payload: {
...snapshot,
files: orderCheckoutDiffFiles(payload.files),
requestId: payload.requestId ?? `subscription:${subscriptionId}`,
},
});
};
subscription.subscribe({
snapshot: apply,
update: (message) => {
if (message.type === "checkout_diff_update") apply(message.payload);
snapshot: (state) => {
if (input.active.get(key) !== registration) return;
const snapshot = checkoutDiffSnapshot(desired.cwd, state);
setCheckoutDiffPayload({
activeCheckoutDiffSubscriptions: input.active,
queryClient: input.queryClient,
serverId: input.serverId,
subscriptionId: key,
payload: { ...snapshot, files: orderCheckoutDiffFiles(snapshot.files) },
});
},
update: () => {},
error: (error) => {
if (input.active.get(key) === registration) input.active.delete(key);
console.error("[server-data] observeCheckoutDiff failed", {
console.error("[server-data] observeDiff failed", {
serverId: input.serverId,
cwd: desired.cwd,
error,
+2 -2
View File
@@ -13,10 +13,10 @@ import {
type DiffTreeRow,
} from "@/git/diff-tree";
import { FileHeader } from "@/git/file-header";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import type { DiffFileHeader } from "@/git/diff-snapshot";
export interface ChangedFilesTreeProps {
files: ParsedDiffFile[];
files: DiffFileHeader[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
collapsedFolderPaths: string[];
+1 -1
View File
@@ -13,6 +13,6 @@ export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[
// chains, so it is skipped; the collapsed set is empty because the flat list
// always carries every file, whatever the rail has collapsed.
return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) =>
row.kind === "file" ? [row.file] : [],
row.kind === "file" ? [files[row.fileIndex]] : [],
);
}
+19 -6
View File
@@ -1,3 +1,4 @@
import type { DiffFileHeader } from "@/git/diff-snapshot";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
@@ -1095,6 +1096,7 @@ interface DiffBodyContentProps {
emptyMessage: string;
emptyAction: ChangesEmptyAction | null;
children: ReactElement;
loadingLabel: string;
checkingRepositoryLabel: string;
notRepositoryLabel: string;
}
@@ -1111,11 +1113,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>
@@ -1137,12 +1144,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) {
@@ -1321,7 +1332,7 @@ function ChangesTreeRail({
}: {
shown: boolean;
children: ReactElement;
files: ParsedDiffFile[];
files: DiffFileHeader[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
treeWidth?: number;
@@ -1361,7 +1372,7 @@ function ChangesBody({
children: ReactElement;
desktopTreeVisible: boolean;
isMobile: boolean;
files: ParsedDiffFile[];
files: DiffFileHeader[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
treeWidth?: number;
@@ -1586,6 +1597,7 @@ export function ChangesSurface({
selectBase: handleSelectBase,
selectAll: handleSelectAll,
files,
treeFiles,
diffPayloadError,
diffTooLarge,
isDiffLoading,
@@ -1835,6 +1847,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")}
>
@@ -1865,7 +1878,7 @@ export function ChangesSurface({
presentation={presentation}
desktopTreeVisible={desktopTreeVisible}
isMobile={isMobile}
files={files}
files={treeFiles}
mode={workingMode}
onSelectFile={handleSelectTreeFile}
treeWidth={instanceState.treeWidth}
+4 -2
View File
@@ -1,7 +1,8 @@
import { replaceEqualDeep } from "@tanstack/react-query";
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
import type { CheckoutDiffSnapshot } from "./diff-snapshot";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
type DiffPayload = CheckoutDiffSnapshot;
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
export function retainDiffFiles(
@@ -30,6 +31,7 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
error === old.error &&
old.cwd === next.cwd &&
old.requestId === next.requestId &&
old.entries === next.entries &&
old.diffTooLarge === next.diffTooLarge
? old
: { ...next, files, error };
+74
View File
@@ -0,0 +1,74 @@
import type { DiffState, DiffEntry } from "@getpaseo/client";
import type {
DiffError,
ParsedDiffFile,
SubscribeCheckoutDiffRequest,
} from "@getpaseo/protocol/messages";
export type DiffFileHeader = Omit<ParsedDiffFile, "hunks">;
export interface CheckoutDiffSnapshot {
requestId: string;
cwd: string;
files: ParsedDiffFile[];
entries?: DiffEntry[];
error: DiffError | null;
diffTooLarge?: boolean;
}
export function checkoutDiffSnapshot(cwd: string, state: DiffState): CheckoutDiffSnapshot {
const common = { cwd, requestId: `diff:${state.revision}:${state.phase}` };
if (state.phase === "loading")
return { ...common, files: [], entries: state.entries, error: null };
if (state.phase === "error") return { ...common, files: [], error: state.error };
return { ...common, files: state.files, error: null, diffTooLarge: state.diffTooLarge };
}
export function diffEntryHeaders(entries: DiffEntry[]): DiffFileHeader[] {
return entries.map((entry) => ({
path: entry.path,
...(entry.oldPath === null ? {} : { oldPath: entry.oldPath }),
isNew: entry.change === "added",
isDeleted: entry.change === "deleted",
additions: entry.additions,
deletions: entry.deletions,
status: entry.content === "binary" ? "binary" : "ok",
}));
}
export interface CheckoutDiffResult {
files: ParsedDiffFile[];
entries: DiffEntry[] | null;
payloadError: CheckoutDiffSnapshot["error"];
diffTooLarge: boolean;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: null;
}
/**
* The result derives from the payload alone. Until a payload arrives there is no
* diff to describe, so the boundary reports loading rather than an empty diff —
* whether the query is idle (an inactive retained panel), in flight, or the host
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
* and "No changes" for a diff nobody has fetched yet.
*/
export function deriveCheckoutDiffResult(payload: CheckoutDiffSnapshot | null): CheckoutDiffResult {
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
entries: payload?.entries ?? null,
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null || payload.entries !== undefined,
isFetching: false,
isError: Boolean(payloadError),
error: null,
};
}
export function checkoutDiffComparisonError(
compare: Pick<SubscribeCheckoutDiffRequest["compare"], "mode" | "baseRef">,
): DiffError | null {
if (compare.mode === "uncommitted" || compare.baseRef?.trim()) return null;
return {
code: "INVALID_COMPARE",
message: "Comparison base is unavailable. Select Uncommitted to view pending changes.",
};
}
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import type { DiffFileHeader } from "@/git/diff-snapshot";
// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view
// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view
// renders. The tree renders on every form factor, consistent with the Files
// explorer.
//
@@ -17,7 +17,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query";
export interface DiffTreeFileNode {
kind: "file";
file: ParsedDiffFile;
file: DiffFileHeader;
fileIndex: number;
/** basename, e.g. "diff-pane.tsx" */
name: string;
@@ -47,7 +47,7 @@ export interface DiffTreeFolderRow {
export interface DiffTreeFileRow {
kind: "file";
file: ParsedDiffFile;
file: DiffFileHeader;
fileIndex: number;
depth: number;
}
@@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void {
}
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode {
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
+4 -4
View File
@@ -28,11 +28,11 @@ import {
fileNameForPath,
formatDiffCount,
} from "@/git/file-header-presentation";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import type { DiffFileHeader } from "@/git/diff-snapshot";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
export interface FileHeaderProps {
file: ParsedDiffFile;
file: DiffFileHeader;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
bodyVisible: boolean;
showsBodyState?: boolean;
@@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
return showsBodyState ? bodyVisible : undefined;
}
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) {
if (!canvasRendered) return undefined;
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
}
@@ -130,7 +130,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
];
}
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" {
return diffFileChangeKind(file);
}
+26 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { deriveCheckoutDiffResult } from "./use-diff-query";
import { deriveCheckoutDiffResult } from "./diff-snapshot";
function createParsedDiffFile(path: string): ParsedDiffFile {
return {
@@ -59,6 +59,7 @@ describe("deriveCheckoutDiffResult", () => {
expect(result).toEqual({
files: [],
entries: null,
payloadError: error,
diffTooLarge: true,
isLoading: false,
@@ -68,3 +69,27 @@ describe("deriveCheckoutDiffResult", () => {
});
});
});
it("shows the announced tree while file bodies are still loading", () => {
const entries = [
{
path: "src/app.ts",
oldPath: null,
change: "modified",
content: "text",
additions: 3,
deletions: 1,
},
] as const;
const result = deriveCheckoutDiffResult({ ...createPayload([]), entries: [...entries] });
expect(result.isLoading).toBe(true);
expect(result.entries).toEqual(entries);
expect(result.files).toEqual([]);
});
it("settles a comparison without a base and leaves Uncommitted available", async () => {
const { checkoutDiffComparisonError } = await import("./diff-snapshot");
expect(checkoutDiffComparisonError({ mode: "all" })).toMatchObject({ code: "INVALID_COMPARE" });
expect(checkoutDiffComparisonError({ mode: "base" })).toMatchObject({ code: "INVALID_COMPARE" });
expect(checkoutDiffComparisonError({ mode: "uncommitted" })).toBeNull();
});
+19 -42
View File
@@ -5,7 +5,13 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
import { useReplicaQuery } from "@/data/query";
import { checkoutDiffPushRoute } from "@/data/push-router";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
import {
checkoutDiffComparisonError,
deriveCheckoutDiffResult,
type CheckoutDiffSnapshot,
} from "./diff-snapshot";
export { deriveCheckoutDiffResult } from "./diff-snapshot";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { checkoutDiffQueryKey } from "@/git/query-keys";
interface UseCheckoutDiffQueryOptions {
@@ -18,7 +24,7 @@ interface UseCheckoutDiffQueryOptions {
queryScope?: string;
}
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
type CheckoutDiffQueryPayload = CheckoutDiffSnapshot;
// Re-export the canonical protocol type so all consumers share one definition.
export type { ParsedDiffFile };
@@ -77,7 +83,11 @@ export function useCheckoutDiffQuery({
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
const comparisonError =
queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null;
const routeEnabled = Boolean(
queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError,
);
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
queryKey,
@@ -97,46 +107,13 @@ export function useCheckoutDiffQuery({
}),
});
return deriveCheckoutDiffResult(
const boundaryError =
requiresUpdate && isConnected
? {
requestId: "unsupported",
cwd,
files: [],
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
}
? { code: "UNKNOWN" as const, message: "Update the host to view All changes" }
: comparisonError;
return deriveCheckoutDiffResult(
boundaryError
? { requestId: "unavailable", cwd, files: [], error: boundaryError }
: (query.data ?? null),
);
}
export interface CheckoutDiffResult {
files: ParsedDiffFile[];
payloadError: CheckoutDiffQueryPayload["error"];
diffTooLarge: boolean;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: null;
}
/**
* The result derives from the payload alone. Until a payload arrives there is no
* diff to describe, so the boundary reports loading rather than an empty diff —
* whether the query is idle (an inactive retained panel), in flight, or the host
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
* and "No changes" for a diff nobody has fetched yet.
*/
export function deriveCheckoutDiffResult(
payload: CheckoutDiffQueryPayload | null,
): CheckoutDiffResult {
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null,
isFetching: false,
isError: Boolean(payloadError),
error: null,
};
}
+10 -1
View File
@@ -9,6 +9,8 @@ import {
useReviewAttachmentSnapshot,
useReviewFeedback,
} from "@/review";
import { useSessionStore } from "@/stores/session-store";
import { diffEntryHeaders } from "./diff-snapshot";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
@@ -44,7 +46,11 @@ export function useWorkingDiff({
const statusErrorMessage =
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
const modernDiff = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.diffSubscription === true,
);
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
const baseRef = (modernDiff ? gitStatus?.comparisonBaseRef : gitStatus?.baseRef) ?? undefined;
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
@@ -59,6 +65,7 @@ export function useWorkingDiff({
const {
files,
entries,
payloadError: diffPayloadError,
diffTooLarge,
isLoading: isDiffLoading,
@@ -71,6 +78,7 @@ export function useWorkingDiff({
enabled: enabled && isGit,
queryScope,
});
const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]);
const reviewDraftKey = useMemo(
() =>
buildReviewDraftKey({
@@ -112,6 +120,7 @@ export function useWorkingDiff({
selectBase,
selectAll,
files,
treeFiles,
diffPayloadError,
diffTooLarge,
isDiffLoading,
-3
View File
@@ -68,7 +68,6 @@ export interface UseForkAgentInput {
function buildChatHistoryAttachment(input: {
draftId: string;
title: string | null;
serverId: string;
agentId: string;
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
@@ -84,7 +83,6 @@ function buildChatHistoryAttachment(input: {
source: {
serverId: input.serverId,
agentId: input.agentId,
...(input.title ? { title: input.title } : {}),
boundaryMessageId: input.payload.boundaryMessageId,
boundaryCursor: input.payload.boundaryCursor,
itemCount: input.payload.itemCount,
@@ -149,7 +147,6 @@ export function useForkAgent(
const payload = await client.buildAgentForkContext(agentId, boundary);
const attachment = buildChatHistoryAttachment({
draftId,
title: useSessionStore.getState().sessions[serverId]?.agents.get(agentId)?.title ?? null,
serverId,
agentId,
payload,
-9
View File
@@ -342,14 +342,6 @@ export const ar: TranslationResources = {
openFile: "افتح الملف",
copied: "منقول",
},
chatHistory: {
title: "سجل المحادثة",
previousConversation: "المحادثة السابقة",
open: "فتح مرفق سجل المحادثة",
remove: "إزالة مرفق سجل المحادثة",
openSource: "فتح المحادثة الأصلية",
description: "السياق المنسوخ عند تفريع هذه المحادثة.",
},
attachments: {
dismissImage: "تجاهل الصورة",
closeImage: "إغلاق الصورة",
@@ -1244,7 +1236,6 @@ export const ar: TranslationResources = {
hosts: "المضيفون",
settings: "إعدادات",
closeSidebar: "إغلاق الشريط الجانبي",
moreActions: "المزيد من الإجراءات",
},
footer: {
usage: "الاستخدام",
-9
View File
@@ -339,14 +339,6 @@ export const en = {
openFile: "Open file",
copied: "Copied",
},
chatHistory: {
title: "Chat history",
previousConversation: "Previous conversation",
open: "Open chat history attachment",
remove: "Remove chat history attachment",
openSource: "Open source chat",
description: "Context copied when this chat was forked.",
},
attachments: {
dismissImage: "Dismiss image",
closeImage: "Close image",
@@ -1252,7 +1244,6 @@ export const en = {
hosts: "Hosts",
settings: "Settings",
closeSidebar: "Close sidebar",
moreActions: "More actions",
},
footer: {
usage: "Usage",
-9
View File
@@ -347,14 +347,6 @@ export const es: TranslationResources = {
openFile: "Abrir archivo",
copied: "Copiado",
},
chatHistory: {
title: "Historial del chat",
previousConversation: "Conversación anterior",
open: "Abrir el historial adjunto",
remove: "Quitar el historial adjunto",
openSource: "Abrir el chat de origen",
description: "Contexto copiado al bifurcar este chat.",
},
attachments: {
dismissImage: "Descartar imagen",
closeImage: "Cerrar imagen",
@@ -1280,7 +1272,6 @@ export const es: TranslationResources = {
hosts: "Hosts",
settings: "Ajustes",
closeSidebar: "Cerrar barra lateral",
moreActions: "Más acciones",
},
footer: {
usage: "Uso",
-9
View File
@@ -347,14 +347,6 @@ export const fr: TranslationResources = {
openFile: "Ouvrir le fichier",
copied: "Copié",
},
chatHistory: {
title: "Historique du chat",
previousConversation: "Conversation précédente",
open: "Ouvrir l’historique joint",
remove: "Retirer l’historique joint",
openSource: "Ouvrir le chat d’origine",
description: "Contexte copié lors de la bifurcation de ce chat.",
},
attachments: {
dismissImage: "Ignorer l’image",
closeImage: "Fermer l’image",
@@ -1274,7 +1266,6 @@ export const fr: TranslationResources = {
hosts: "Hôtes",
settings: "Paramètres",
closeSidebar: "Fermer la barre latérale",
moreActions: "Plus d’actions",
},
footer: {
usage: "Utilisation",
-9
View File
@@ -347,14 +347,6 @@ export const ja: TranslationResources = {
openFile: "ファイルを開く",
copied: "コピーしました",
},
chatHistory: {
title: "チャット履歴",
previousConversation: "以前の会話",
open: "チャット履歴の添付を開く",
remove: "チャット履歴の添付を削除",
openSource: "元のチャットを開く",
description: "このチャットを分岐した時点でコピーされたコンテキストです。",
},
attachments: {
dismissImage: "画像を閉じる",
closeImage: "画像を閉じる",
@@ -1258,7 +1250,6 @@ export const ja: TranslationResources = {
hosts: "ホスト",
settings: "設定",
closeSidebar: "サイドバーを閉じる",
moreActions: "その他のアクション",
},
footer: {
usage: "使用状況",
-9
View File
@@ -343,14 +343,6 @@ export const ko: TranslationResources = {
openFile: "파일 열기",
copied: "복사됨",
},
chatHistory: {
title: "채팅 기록",
previousConversation: "이전 대화",
open: "채팅 기록 첨부 열기",
remove: "채팅 기록 첨부 제거",
openSource: "원본 채팅 열기",
description: "이 채팅을 분기할 때 복사된 컨텍스트입니다.",
},
attachments: {
dismissImage: "이미지 닫기",
closeImage: "이미지 닫기",
@@ -1251,7 +1243,6 @@ export const ko: TranslationResources = {
hosts: "호스트",
settings: "설정",
closeSidebar: "사이드바 닫기",
moreActions: "추가 작업",
},
footer: {
usage: "사용량",
-9
View File
@@ -346,14 +346,6 @@ export const ptBR: TranslationResources = {
openFile: "Abrir arquivo",
copied: "Copiado",
},
chatHistory: {
title: "Histórico do chat",
previousConversation: "Conversa anterior",
open: "Abrir histórico anexado",
remove: "Remover histórico anexado",
openSource: "Abrir chat de origem",
description: "Contexto copiado ao ramificar este chat.",
},
attachments: {
dismissImage: "Dispensar imagem",
closeImage: "Fechar imagem",
@@ -1270,7 +1262,6 @@ export const ptBR: TranslationResources = {
hosts: "Hosts",
settings: "Configurações",
closeSidebar: "Fechar barra lateral",
moreActions: "Mais ações",
},
footer: {
usage: "Uso",
-9
View File
@@ -346,14 +346,6 @@ export const ru: TranslationResources = {
openFile: "Открыть файл",
copied: "Скопировано",
},
chatHistory: {
title: "История чата",
previousConversation: "Предыдущий разговор",
open: "Открыть вложенную историю чата",
remove: "Удалить вложенную историю чата",
openSource: "Открыть исходный чат",
description: "Контекст, скопированный при создании этой ветки чата.",
},
attachments: {
dismissImage: "Закрыть изображение",
closeImage: "Закрыть изображение",
@@ -1262,7 +1254,6 @@ export const ru: TranslationResources = {
hosts: "Хосты",
settings: "Настройки",
closeSidebar: "Закрыть боковую панель",
moreActions: "Дополнительные действия",
},
footer: {
usage: "Использование",
-9
View File
@@ -342,14 +342,6 @@ export const zhCN: TranslationResources = {
openFile: "打开文件",
copied: "已复制",
},
chatHistory: {
title: "聊天历史",
previousConversation: "之前的对话",
open: "打开聊天历史附件",
remove: "移除聊天历史附件",
openSource: "打开来源对话",
description: "创建此分叉时复制的上下文。",
},
attachments: {
dismissImage: "关闭图片",
closeImage: "关闭图片",
@@ -1236,7 +1228,6 @@ export const zhCN: TranslationResources = {
hosts: "Hosts",
settings: "设置",
closeSidebar: "关闭侧边栏",
moreActions: "更多操作",
},
footer: {
usage: "使用情况",
+3 -9
View File
@@ -43,10 +43,7 @@ import { RewindComposerRestoreProvider } from "@/components/rewind/composer-rest
import { useAgentDescriptor } from "@/agent-presentation";
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import {
useWorkspaceAttachmentScopeKey,
useAgentAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
@@ -1115,7 +1112,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const parentAgentId = useParentAgentId(serverId, agentId);
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
const parentPillAgentId = showsComposer ? parentAgentId : null;
const rewindAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
@@ -1249,7 +1245,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const content = (
<RewindComposerRestoreProvider
attachmentScopeKey={rewindAttachmentScopeKey}
textSource={agentInputDraft.textSource}
setText={agentInputDraft.replaceText}
onRewindComplete={handleRewindComplete}
@@ -1546,10 +1541,9 @@ function ActiveAgentComposer({
cwd,
workspaceId,
});
const agentAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
const attachmentScopeKeys = useMemo(
() => [workspaceAttachmentScopeKey, agentAttachmentScopeKey],
[workspaceAttachmentScopeKey, agentAttachmentScopeKey],
() => [workspaceAttachmentScopeKey],
[workspaceAttachmentScopeKey],
);
const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => {
@@ -321,26 +321,6 @@ describe("ReplicaCache", () => {
expect(restored?.range).toBeNull();
});
it("preserves workspace parentage and its checkpoint across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } });
const workspace = normalizeWorkspaceDescriptor({
...workspacePayload(),
parentWorkspaceId: "parent",
});
baseline.workspaces.set(workspace.id, workspace);
commitDirectory(writer, SERVER_ID, baseline);
await writer.flush();
const reader = createCache(storage);
const restored = await reader.readDirectory(SERVER_ID);
expect(restored.checkpoint).toEqual(baseline.checkpoint);
expect(restored.workspaces.get(workspace.id)?.parentWorkspaceId).toBe("parent");
expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace.parentWorkspaceId).toBe(
"parent",
);
});
it("preserves workspace sorting timestamps across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
@@ -299,7 +299,6 @@ const WorkspaceGitRuntimeSchema = z
.optional();
const StoredWorkspaceSchema = z.strictObject({
parentWorkspaceId: z.string().optional(),
// Require both keys: a cursor without these values would preserve the wrong sort on reconnect.
createdAt: IsoDateSchema.nullable(),
lastUserMessageAt: IsoDateSchema.nullable(),
@@ -708,7 +707,6 @@ function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor {
function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
return {
parentWorkspaceId: workspace.parentWorkspaceId,
createdAt: workspace.createdAt?.toISOString() ?? null,
lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null,
id: workspace.id,
@@ -557,33 +557,6 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(blank.workspaceDirectory).toBe("");
});
it("preserves informational workspace parentage", () => {
const payload = {
id: "child",
parentWorkspaceId: "parent",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
} as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.parentWorkspaceId).toBe("parent");
expect(
normalizeWorkspaceDescriptor({ ...payload, parentWorkspaceId: undefined }).parentWorkspaceId,
).toBeUndefined();
});
it("defaults missing scripts to an empty array", () => {
const payload = {
id: "1",
-2
View File
@@ -105,7 +105,6 @@ export interface Agent {
}
export interface WorkspaceDescriptor {
parentWorkspaceId?: string;
createdAt: Date | null;
lastUserMessageAt: Date | null;
id: string;
@@ -163,7 +162,6 @@ export function normalizeWorkspaceDescriptor(
projectKind: payload.projectKind,
workspaceKind: payload.workspaceKind,
background: payload.background ?? false,
parentWorkspaceId: payload.parentWorkspaceId,
name: payload.name,
title: payload.title ?? null,
pinnedAt: payload.pinnedAt ?? null,
-99
View File
@@ -9,7 +9,6 @@ import {
hydrateStreamState,
mergeToolCallDetail,
reduceStreamUpdate,
replaceWithCanonicalStream,
streamTimelineItemIdentity,
type AgentToolCallItem,
type StreamItem,
@@ -159,104 +158,6 @@ describe("plugin timeline rows", () => {
});
describe("user message identity", () => {
it.each(["", "Continue here"])(
"restores fork history onto a cached user message (%s)",
(text) => {
const cached = createUserMessage({
clientMessageId: "fork-client",
messageId: "fork-provider",
text,
timestamp: new Date(0),
timelineCursor: { epoch: "timeline", seq: 1 },
});
const history = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Earlier question\n[Assistant] Earlier answer",
} as const;
const canonical = createUserMessage({
...cached,
attachments: [history],
});
const result = replaceWithCanonicalStream({
canonical: [canonical],
previousTail: [cached],
previousHead: [],
sendingClientMessageIds: [],
preserveContinuity: true,
canonicalCoverage: { epoch: "timeline", endSeq: 1 },
});
expect(result.tail).toHaveLength(1);
expect(result.tail[0]).toMatchObject({ text, attachments: [history] });
},
);
it.each(["", "Continue from here"])(
"hydrates fork history without optimistic state: %s",
(text) => {
const chatHistory = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Earlier question\n[Assistant] Earlier answer",
chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" },
} as const;
const result = reduceStreamUpdate(
[],
{
type: "timeline",
provider: "codex",
item: { type: "user_message", text, messageId: "fork-prompt", chatHistory },
},
new Date(0),
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ kind: "user_message", text, attachments: [chatHistory] });
},
);
it.each([
{ text: "", insert: "tail" },
{ text: "Continue", insert: "tail" },
{ text: "", insert: "head" },
{ text: "Continue", insert: "head" },
] as const)("delivers fork history to a live viewer ($insert, $text)", ({ text, insert }) => {
const chatHistory = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Original question",
chatHistorySource: { serverId: "source-host", agentId: "source-agent" },
} as const;
const event = {
type: "timeline",
provider: "codex",
item: { type: "user_message", text, messageId: "fork-message", chatHistory },
} as const;
const first = applyStreamEvent({
tail: [],
head: [],
event,
timestamp: new Date(0),
unmatchedUserMessageInsert: insert,
});
expect(first[insert]).toHaveLength(1);
expect(first[insert][0]).toMatchObject({
kind: "user_message",
text,
attachments: [chatHistory],
});
const repeated = applyStreamEvent({
...first,
event,
timestamp: new Date(0),
unmatchedUserMessageInsert: insert,
});
expect([...repeated.tail, ...repeated.head]).toHaveLength(1);
expect([...repeated.tail, ...repeated.head][0]).toMatchObject({ attachments: [chatHistory] });
});
it("replaces provisional optimistic turn membership with canonical membership", () => {
const optimistic = createUserMessage({
clientMessageId: "hello-client",
+2 -19
View File
@@ -263,7 +263,6 @@ function produceUserMessage(
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
messageId: incoming.messageId ?? existing.messageId,
timelineCursor: incoming.timelineCursor ?? existing.timelineCursor,
attachments: presentation.attachments ?? incoming.attachments,
});
if (
existing.id === merged.id &&
@@ -892,18 +891,6 @@ export function handoffCreatedAgentUserMessageToStream(params: {
});
}
function normalizeUserMessageContent(
text: string,
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
) {
const normalized = normalizeChunk(text);
return {
...normalized,
hasContent: normalized.hasContent || chatHistory !== undefined,
attachments: chatHistory ? [chatHistory] : undefined,
};
}
function appendUserMessage(
state: StreamItem[],
text: string,
@@ -913,9 +900,8 @@ function appendUserMessage(
clientMessageId?: string,
timelineCursor?: TimelinePosition,
turnId?: string,
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
): StreamItem[] {
const { chunk, hasContent, attachments } = normalizeUserMessageContent(text, chatHistory);
const { chunk, hasContent } = normalizeChunk(text);
if (!hasContent) {
return state;
}
@@ -928,7 +914,6 @@ function appendUserMessage(
timelineCursor,
turnId,
text: chunk,
attachments,
timestamp,
});
return upsertUserMessage(state, nextItem);
@@ -1538,7 +1523,6 @@ function reduceTimelineEvent(
item.clientMessageId,
timelineCursor,
event.turnId,
item.chatHistory,
);
case "assistant_message":
return appendAssistantMessage(
@@ -1890,7 +1874,7 @@ function applyCanonicalUserMessageEvent(params: {
}): ApplyStreamEventResult | null {
const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params;
if (event.type !== "timeline" || event.item.type !== "user_message") return null;
const normalized = normalizeUserMessageContent(event.item.text, event.item.chatHistory);
const normalized = normalizeChunk(event.item.text);
const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail;
const flushedHead = head.length > 0 ? [] : head;
@@ -1903,7 +1887,6 @@ function applyCanonicalUserMessageEvent(params: {
turnId: event.turnId,
timelineCursor,
text: normalized.chunk,
attachments: normalized.attachments,
timestamp,
});
if (unmatchedInsert === "head") {
+7 -14
View File
@@ -33,10 +33,9 @@ import {
} from "./model";
import type { UsageReportEntry, UsageView } from "./types";
// The daemon caches each report for five minutes, so re-reading it is cheap.
// An explicit refresh passes `forceRefresh` to bypass that cache.
// The daemon caches each report for five minutes, so re-reading it is cheap. Only
// an explicit refresh passes `forceRefresh` and reaches the source's API.
const REPORTS_STALE_TIME_MS = 60_000;
const REPORTS_REFETCH_INTERVAL_MS = 5 * 60_000;
/** Every report list of a host: its own and each agent's. */
function hostUsageQueryKey(serverId: string) {
@@ -165,16 +164,11 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const NO_REPORTS: UsageReportEntry[] = [];
interface UsageHostReportsInput {
serverId: string | null;
shouldPoll?: boolean;
}
/** A host's reports, with periodic refresh enabled only by the visible sidebar summary. */
export function useUsageHostReports({
serverId,
shouldPoll = false,
}: UsageHostReportsInput): UsageReportEntry[] {
/**
* The reports of the sidebar's usage host, which is connected and reports usage; none until they
* load, or without a host.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const query = useFetchQuery({
@@ -183,7 +177,6 @@ export function useUsageHostReports({
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false,
});
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
}
+1 -8
View File
@@ -16,8 +16,6 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppVisible } from "@/hooks/use-app-visible";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useUsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
@@ -38,14 +36,9 @@ import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
export function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const active = useRetainedPanelActive();
const isAppVisible = useAppVisible();
const { inSidebar } = useUsageInSidebar();
const serverId = useUsageHostId();
const reports = useUsageHostReports({
serverId: inSidebar ? serverId : null,
shouldPoll: active && inSidebar && isAppVisible,
});
const reports = useUsageHostReports(inSidebar ? serverId : null);
const { preferences } = useUsageDisplay(reports);
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
+2 -1
View File
@@ -45,7 +45,8 @@
"prepack": "npm run build:clean",
"typecheck": "tsgo --noEmit",
"typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit",
"test": "vitest run"
"test": "vitest run",
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
},
"dependencies": {
"@getpaseo/protocol": "0.15.2",
@@ -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;
+61 -2
View File
@@ -1,3 +1,9 @@
import {
DiffStreamRouter,
DiffSubscriptionError,
type DiffCompare,
type DiffState,
} from "./diff/index.js";
import {
ChunkedTransport,
ChunkedTransportProtocolError,
@@ -1411,6 +1417,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>();
@@ -1760,6 +1780,7 @@ 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"));
@@ -2663,7 +2684,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>>(
@@ -2689,12 +2715,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;
}
},
@@ -4660,6 +4690,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 },
@@ -6936,6 +6986,7 @@ export class DaemonClient {
this.chunkedTransport?.close();
this.chunkedTransport = null;
this.owned.disconnected();
this.diffStreams.disconnected();
this.providerSnapshotUpdates.clear();
this.stopLivenessHeartbeat();
this.cleanupTransport();
@@ -7126,6 +7177,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;
@@ -7326,6 +7381,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
@@ -7485,6 +7541,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,
@@ -7761,3 +7818,5 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
cwd: merged.cwd,
};
}
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
+210
View File
@@ -0,0 +1,210 @@
import { expect, test } from "vitest";
import { DaemonClient, type DaemonTransport } from "../daemon-client.js";
import {
encodeDiffFrame,
DiffOpcode,
type DiffFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { DiffState } from "./index.js";
function socket() {
let message: (data: unknown, binary?: boolean) => void = () => {};
let open: () => void = () => {};
let close: (event?: unknown) => void = () => {};
const sent: Record<string, unknown>[] = [];
const transport: DaemonTransport = {
send: (data) => {
if (typeof data === "string") {
const value = JSON.parse(data);
if (value.type === "session") {
sent.push(value.message);
if (value.message.type === "subscription.release.request")
queueMicrotask(() =>
receive({
type: "subscription.release.response",
payload: {
requestId: value.message.requestId,
subscriptionId: value.message.subscriptionId,
released: true,
},
}),
);
}
}
},
close: () => {},
onMessage: (handler) => {
message = handler;
return () => {};
},
onOpen: (handler) => {
open = handler;
return () => {};
},
onClose: (handler) => {
close = handler;
return () => {};
},
onError: () => () => {},
};
const receive = (value: unknown) =>
message(JSON.stringify({ type: "session", message: value }), false);
return {
transport,
sent,
receive,
raw: (bytes: Uint8Array) => message(bytes, true),
frame: (frame: DiffFrame) => message(encodeDiffFrame(frame), true),
disconnect: () => close({ code: 1001 }),
open: (modern = true) => {
open();
receive({
type: "status",
payload: {
status: "server_info",
serverId: "srv_test",
hostname: null,
version: null,
features: { ownedSubscriptions: true, diffSubscription: modern, checkoutDiffAll: true },
},
});
},
};
}
const compare = {
cwd: "/repo",
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
} as const;
const begin: DiffFrame = {
opcode: DiffOpcode.Begin,
slot: 3,
revision: 1,
baseRevision: 0,
baseSha: "a",
headSha: "b",
mergeBaseSha: "",
entries: [],
};
const end: DiffFrame = {
opcode: DiffOpcode.End,
slot: 3,
revision: 1,
diffTooLarge: false,
omitted: [],
error: null,
};
async function setup(modern = true) {
const wire = socket();
const client = new DaemonClient({
url: "ws://test",
clientId: "diff-test",
transportFactory: () => wire.transport,
reconnect: { enabled: false },
});
const connecting = client.connect();
wire.open(modern);
await connecting;
return { wire, client };
}
function acknowledge(wire: ReturnType<typeof socket>, ordinal = 1) {
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
wire.receive({
type: "diff.subscribe.response",
payload: {
requestId: request?.requestId,
subscriptionId: `diff-${ordinal}`,
slot: 3,
error: null,
},
});
}
test("attaches binary routing during handshake and resubscribes with a full state on reconnect", async () => {
const { wire, client } = await setup();
try {
const states: DiffState[] = [];
const observation = client.observeDiff(compare);
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
acknowledge(wire);
wire.frame(begin);
wire.frame(end);
expect(states.map((state) => state.phase)).toEqual(["loading", "ready"]);
wire.disconnect();
const connected = client.connect();
wire.open();
await connected;
acknowledge(wire, 2);
wire.frame(begin);
wire.frame(end);
expect(states.map((state) => state.phase)).toEqual(["loading", "ready", "loading", "ready"]);
await observation.release();
} finally {
await client.close();
}
});
test("an unknown version fails only its subscription and readDiff resolves typed subscribe errors", async () => {
const { wire, client } = await setup();
try {
const observation = client.observeDiff(compare);
const states: DiffState[] = [];
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
acknowledge(wire);
const malformed = { ...begin, slot: 3 };
const bytes = encodeDiffFrame(malformed);
bytes[2] = 255;
wire.raw(bytes);
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "UNKNOWN" } });
const next = client.readDiff(compare);
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
wire.receive({
type: "diff.subscribe.response",
payload: {
requestId: request?.requestId,
subscriptionId: "",
slot: 0,
error: { code: "INVALID_COMPARE", message: "invalid" },
},
});
expect(await next).toMatchObject({ phase: "error", error: { code: "INVALID_COMPARE" } });
expect(client.isConnected).toBe(true);
await observation.release();
} finally {
await client.close();
}
});
test.each(["uncommitted", "base", "all"] as const)(
"old hosts serve the %s comparison through the tagged compatibility route",
async (mode) => {
const { wire, client } = await setup(false);
try {
const comparison =
mode === "uncommitted"
? compare
: {
...compare,
base: { kind: "rev", rev: "refs/tags/v1" } as const,
head: mode === "base" ? ({ kind: "rev", rev: "HEAD" } as const) : compare.head,
mergeBase: true,
};
const result = client.readDiff(comparison);
const request = wire.sent.find((value) => value.type === "subscribe_checkout_diff_request");
expect(request?.compare).toMatchObject({ mode });
wire.receive({
type: "subscribe_checkout_diff_response",
payload: {
requestId: request?.requestId,
subscriptionId: "legacy",
cwd: "/repo",
files: [],
error: null,
},
});
wire.receive({
type: "checkout_diff_update",
payload: { subscriptionId: "legacy", cwd: "/repo", files: [], error: null },
});
expect(await result).toMatchObject({ phase: "ready", files: [] });
} finally {
await client.close();
}
},
);
+246
View File
@@ -0,0 +1,246 @@
import {
decodeDiffFrame,
type DiffEntry as WireEntry,
type DiffBegin,
} from "@getpaseo/protocol/binary-frames/index";
import type {
DiffSubscribeRequest,
SubscribeCheckoutDiffRequest,
DiffError,
DiffSubscribeResponse,
SubscribeCheckoutDiffResponse,
ParsedDiffFile,
} from "@getpaseo/protocol/messages";
import type { OwnedSubscription, SubscriptionObserver } from "../connection/index.js";
import { DiffStream } from "./stream.js";
export type { DiffEndpoint } from "@getpaseo/protocol/messages";
export type DiffCompare = Omit<DiffSubscribeRequest, "type" | "requestId">;
export type DiffEntry = Omit<WireEntry, "body">;
export type ResolvedShas = Pick<DiffBegin, "baseSha" | "headSha" | "mergeBaseSha">;
export type DiffState =
| { phase: "loading"; revision: number; entries: DiffEntry[] }
| {
phase: "ready";
revision: number;
resolved: ResolvedShas;
diffTooLarge: boolean;
files: ParsedDiffFile[];
}
| { phase: "error"; revision: number; error: DiffError };
type LegacyCompare = SubscribeCheckoutDiffRequest["compare"];
interface Port {
modern(compare: DiffCompare): OwnedSubscription<DiffSubscribeResponse["payload"]>;
legacy(
cwd: string,
compare: LegacyCompare,
): OwnedSubscription<SubscribeCheckoutDiffResponse["payload"]>;
failed(error: unknown): void;
}
export class DiffSubscriptionError extends Error {
constructor(readonly detail: DiffError) {
super(detail.message);
}
}
interface Registration {
start(modern: boolean): void;
disconnected(): void;
release(): Promise<void>;
}
/** Capability selection, slot ownership and reconnect belong to this domain. */
export class DiffStreamRouter {
private registrations = new Set<Registration>();
private slots = new Map<number, { receive(bytes: Uint8Array): void }>();
private mode: boolean | null = null;
constructor(private port: Port) {}
connected(modern: boolean): void {
this.mode = modern;
for (const registration of this.registrations) registration.start(modern);
}
disconnected(): void {
this.mode = null;
this.slots.clear();
for (const registration of this.registrations) registration.disconnected();
}
async close(): Promise<void> {
await Promise.all([...this.registrations].map((registration) => registration.release()));
}
receive(bytes: Uint8Array): void {
const owner = this.slots.get(bytes[1]);
owner?.receive(bytes);
}
private releaseBackend(backend: OwnedSubscription<unknown> | null): void {
void backend?.release().catch(this.port.failed);
}
observe(compare: DiffCompare): OwnedSubscription<DiffState> {
let backend: OwnedSubscription<unknown> | null = null;
let state: (DiffState & { subscriptionId: string }) | null = null;
let slot: number | null = null;
let released = false;
let generation = 0;
let stream: DiffStream;
const observers = new Set<SubscriptionObserver<DiffState & { subscriptionId: string }>>();
let resolve!: (state: DiffState & { subscriptionId: string }) => void;
const ready = new Promise<DiffState & { subscriptionId: string }>((accept) => {
resolve = accept;
});
const publish = (value: DiffState) => {
if (released) return;
state = { ...value, subscriptionId: backend?.subscriptionId ?? "" };
resolve(state);
for (const observer of observers) {
try {
observer.snapshot(state);
} catch (error) {
this.port.failed(error);
}
}
};
const detach = () => {
if (slot !== null) this.slots.delete(slot);
slot = null;
};
const fail = (error: unknown) => {
detach();
publish({
phase: "error",
revision: 0,
error:
error instanceof DiffSubscriptionError
? error.detail
: { code: "UNKNOWN", message: error instanceof Error ? error.message : String(error) },
});
this.releaseBackend(backend);
};
const registration: Registration = {
start: (modern) => {
if (released || backend) return;
const current = ++generation;
const failed = (error: unknown) => {
if (current === generation) fail(error);
};
stream = new DiffStream(publish);
if (modern) {
const observation = this.port.modern(compare);
backend = observation;
observation.subscribe({
snapshot: (payload) => {
if (current !== generation) return;
detach();
if (payload.error) {
fail(new DiffSubscriptionError(payload.error));
return;
}
slot = payload.slot;
const owner = {
receive: (bytes: Uint8Array) => {
try {
stream.receive(decodeDiffFrame(bytes));
} catch (error) {
fail(error);
}
},
};
this.slots.set(slot, owner);
},
update: () => {},
error: failed,
});
void observation.ready.catch(failed);
} else {
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
const legacy = legacyCompare(compare);
if (!legacy) {
fail(
new DiffSubscriptionError({
code: "INVALID_COMPARE",
message: "Update the host to use this comparison",
}),
);
return;
}
const observation = this.port.legacy(compare.cwd, legacy);
backend = observation;
const apply = (
payload: Pick<
SubscribeCheckoutDiffResponse["payload"],
"files" | "error" | "diffTooLarge"
>,
) => {
if (current !== generation) return;
if (payload.error)
publish({
phase: "error",
revision: 0,
error: {
code: payload.error.code === "NOT_GIT_REPO" ? "NOT_GIT_REPO" : "UNKNOWN",
message: payload.error.message,
},
});
else
publish({
phase: "ready",
revision: (state?.revision ?? 0) + 1,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
diffTooLarge: payload.diffTooLarge === true,
files: payload.files,
});
};
observation.subscribe({
snapshot: apply,
update: (message) => {
if (message.type === "checkout_diff_update") apply(message.payload);
},
error: failed,
});
void observation.ready.catch(failed);
}
},
disconnected: () => {
generation++;
detach();
state = null;
const old = backend;
backend = null;
void old?.release().catch(this.port.failed);
},
release: async () => {
if (released) return;
released = true;
generation++;
detach();
this.registrations.delete(registration);
await backend?.release();
observers.clear();
},
};
this.registrations.add(registration);
if (this.mode !== null) registration.start(this.mode);
return {
get subscriptionId() {
return backend?.subscriptionId ?? null;
},
ready,
subscribe: (observer) => {
observers.add(observer);
if (state) observer.snapshot(state);
return () => observers.delete(observer);
},
release: () => registration.release(),
};
}
}
function legacyCompare(compare: DiffCompare): LegacyCompare | null {
if (compare.highlight === false) return null;
const { base, head } = compare;
if (base.kind !== "rev") return null;
if (base.rev === "HEAD" && head.kind === "worktree")
return { mode: "uncommitted", ignoreWhitespace: compare.ignoreWhitespace };
if (compare.mergeBase && head.kind === "rev" && head.rev === "HEAD")
return { mode: "base", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
if (compare.mergeBase && head.kind === "worktree")
return { mode: "all", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
return null;
}
+86
View File
@@ -0,0 +1,86 @@
import { expect, test } from "vitest";
import { DiffStream } from "./stream.js";
import { DiffOpcode, type DiffBegin } from "@getpaseo/protocol/binary-frames/index";
import type { DiffState } from "./index.js";
const entry = {
path: "a.ts",
oldPath: null,
change: "modified" as const,
content: "text" as const,
additions: 1,
deletions: 0,
body: true,
};
const begin = (revision: number, baseRevision = 0): DiffBegin => ({
opcode: DiffOpcode.Begin,
slot: 0,
revision,
baseRevision,
baseSha: "a",
headSha: "b",
mergeBaseSha: "",
entries: [entry],
});
const end = (revision: number) =>
({
opcode: DiffOpcode.End,
slot: 0,
revision,
diffTooLarge: false,
omitted: [],
error: null,
}) as const;
test("publishes the initial tree, commits on End and buffers refreshes with stable equal files", () => {
const states: DiffState[] = [];
const stream = new DiffStream((state) => states.push(state));
stream.receive(begin(1));
expect(states[0]).toMatchObject({ phase: "loading", entries: [{ path: "a.ts" }] });
stream.receive({
opcode: DiffOpcode.Bodies,
slot: 0,
revision: 1,
files: [{ path: "a.ts", hunks: [] }],
});
expect(states).toHaveLength(1);
stream.receive(end(1));
const ready = states[1];
if (ready.phase !== "ready") throw new Error("Expected ready");
stream.receive(begin(2, 1));
stream.receive({
opcode: DiffOpcode.Bodies,
slot: 0,
revision: 2,
files: [{ path: "a.ts", hunks: [] }],
});
expect(states).toHaveLength(2);
stream.receive(end(2));
const refreshed = states[2];
if (refreshed.phase !== "ready") throw new Error("Expected ready");
expect(refreshed.files[0]).toBe(ready.files[0]);
});
test("superseded and stale frames cannot leave the client loading; End is authoritative", () => {
const states: DiffState[] = [];
const stream = new DiffStream((s) => states.push(s));
stream.receive(begin(1));
stream.receive(begin(2));
stream.receive(end(1));
expect(() => stream.receive(end(2))).toThrow(/Missing/);
stream.receive(begin(3));
stream.receive({ ...end(3), error: { code: "REF_NOT_FOUND", message: "gone" } });
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "REF_NOT_FOUND" } });
stream.receive({ ...begin(4), entries: [] });
stream.receive(end(4));
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
});
test("unchanged entries retain hunks, omissions override cache, removed paths disappear", () => {
const states: DiffState[] = [];
const stream = new DiffStream((s) => states.push(s));
stream.receive(begin(1));
stream.receive({ ...end(1), omitted: ["a.ts"], diffTooLarge: true });
stream.receive({ ...begin(2, 1), entries: [{ ...entry, body: false }] });
stream.receive(end(2));
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [{ status: "too_large" }] });
stream.receive({ ...begin(3, 2), entries: [] });
stream.receive(end(3));
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
});
+94
View File
@@ -0,0 +1,94 @@
import {
DiffOpcode,
materializeDiffFile,
type DiffBegin,
type DiffBody,
type DiffFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import type { DiffState } from "./index.js";
/** Owns revision assembly. End, rather than a local body count, commits the snapshot. */
export class DiffStream {
private pending: { begin: DiffBegin; bodies: Map<string, DiffBody> } | null = null;
private committed: Extract<DiffState, { phase: "ready" }> | null = null;
constructor(private publish: (state: DiffState) => void) {}
receive(frame: DiffFrame): void {
if (frame.opcode === DiffOpcode.Begin) {
if (frame.baseRevision !== 0 && frame.baseRevision !== this.committed?.revision)
throw new Error("Unknown diff base revision");
if (new Set(frame.entries.map((entry) => entry.path)).size !== frame.entries.length)
throw new Error("Duplicate diff entry");
this.pending = { begin: frame, bodies: new Map() };
if (!this.committed)
this.publish({
phase: "loading",
revision: frame.revision,
entries: frame.entries.map(({ body: _body, ...entry }) => entry),
});
return;
}
const pending = this.pending;
if (!pending || frame.revision !== pending.begin.revision) return;
if (frame.opcode === DiffOpcode.Bodies) {
for (const body of frame.files) {
if (pending.bodies.has(body.path)) throw new Error("Duplicate diff body");
const entry = pending.begin.entries.find((candidate) => candidate.path === body.path);
if (!entry?.body) throw new Error("Unexpected diff body");
pending.bodies.set(body.path, body);
}
return;
}
this.pending = null;
if (frame.error) {
this.committed = null;
this.publish({ phase: "error", revision: frame.revision, error: frame.error });
return;
}
const previous = new Map(this.committed?.files.map((file) => [file.path, file]));
const omitted = new Set(frame.omitted);
for (const path of omitted)
if (!pending.begin.entries.some((entry) => entry.path === path))
throw new Error("Unknown omitted diff path");
const files = pending.begin.entries.map((entry) => {
const old = previous.get(entry.path);
const body = pending.bodies.get(entry.path);
if (entry.content === "text" && !omitted.has(entry.path) && entry.body && !body)
throw new Error(`Missing diff body: ${entry.path}`);
if (
entry.content === "text" &&
!entry.body &&
!omitted.has(entry.path) &&
(!old || pending.begin.baseRevision === 0)
)
throw new Error(`Missing inherited diff: ${entry.path}`);
let file = materializeDiffFile(entry, body ?? null, omitted.has(entry.path));
if (entry.content === "text" && !entry.body && !omitted.has(entry.path) && old)
file = { ...file, hunks: old.hunks, status: old.status };
return old && sameFile(old, file) ? old : file;
});
const { revision, baseSha, headSha, mergeBaseSha } = pending.begin;
this.committed = {
phase: "ready",
revision,
resolved: { baseSha, headSha, mergeBaseSha },
files,
diffTooLarge: frame.diffTooLarge,
};
this.publish(this.committed);
}
}
function sameFile(a: ParsedDiffFile, b: ParsedDiffFile): boolean {
if (
a.path !== b.path ||
a.oldPath !== b.oldPath ||
a.isNew !== b.isNew ||
a.isDeleted !== b.isDeleted ||
a.additions !== b.additions ||
a.deletions !== b.deletions ||
a.status !== b.status
)
return false;
if (a.hunks === b.hunks) return true;
return JSON.stringify(a.hunks) === JSON.stringify(b.hunks);
}
+2
View File
@@ -1212,3 +1212,5 @@ function createGeneratedClientId(): string {
: Math.random().toString(36).slice(2);
return `paseo-sdk-${randomId}`;
}
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
+2 -8
View File
@@ -1,5 +1,5 @@
import type { AgentMessage } from "./agent-message.js";
import type { AgentAttachment, ChatHistoryAttachment } from "./messages.js";
import type { AgentAttachment } from "./messages.js";
export type AgentProvider = string;
@@ -373,13 +373,7 @@ export interface AgentTaskItem {
}
export type AgentTimelineItem =
| {
type: "user_message";
text: string;
messageId?: string;
clientMessageId?: string;
chatHistory?: ChatHistoryAttachment;
}
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
@@ -1,3 +1,4 @@
import { decodeDiffFrame, DiffOpcode, type DiffFrame } from "./diff.js";
import { HOST_ROUTE_OPCODE, decodeHostRouteFrame, type HostRouteFrame } from "./host-route.js";
import {
decodeFileTransferFrame,
@@ -12,12 +13,20 @@ import {
} from "./terminal.js";
export type BinaryFrame =
| { kind: "diff"; frame: DiffFrame }
| { kind: "host_route"; frame: HostRouteFrame }
| { kind: "terminal"; frame: TerminalStreamFrame }
| { kind: "file_transfer"; frame: FileTransferFrame }
| { kind: "plugin"; frame: PluginFrame };
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
if (Object.values(DiffOpcode).some((opcode) => opcode === bytes[0])) {
return { kind: "diff", frame: decodeDiffFrame(bytes) };
}
return decodeExistingBinaryFrame(bytes);
}
function decodeExistingBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
switch (bytes[0]) {
case HOST_ROUTE_OPCODE: {
const frame = decodeHostRouteFrame(bytes);
@@ -0,0 +1,270 @@
import { expect, test } from "vitest";
import { decodeDiffFrame, encodeDiffFrame } from "./diff.js";
import { decodeBinaryFrame } from "./demux.js";
import { decodePluginFrame } from "./plugin.js";
test("routes diff revisions independently of plugin frames", () => {
const frame = {
opcode: 0x40 as const,
slot: 0,
revision: 1,
baseRevision: 0,
baseSha: "",
headSha: "",
mergeBaseSha: "",
entries: [],
};
const bytes = encodeDiffFrame(frame);
expect([...bytes]).toEqual([64, 0, 1, 1, 0, 0, 0, 0, 0]);
expect(decodeDiffFrame(bytes)).toEqual(frame);
expect(decodeBinaryFrame(bytes)).toEqual({ kind: "diff", frame });
expect(decodePluginFrame(bytes)).toBeNull();
});
test("roundtrips Unicode runs and materializes the existing file shape", async () => {
const { materializeDiffFile } = await import("./diff.js");
const frame = {
opcode: 0x41 as const,
slot: 255,
revision: 128,
files: [
{
path: "é.ts",
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{
type: "add" as const,
content: "😀é",
runs: [
{ style: "string", length: 2 },
{ style: null, length: 2 },
],
},
],
},
],
},
],
};
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
const file = materializeDiffFile(
{
path: "é.ts",
oldPath: null,
change: "modified",
content: "text",
additions: 1,
deletions: 1,
body: true,
},
frame.files[0],
false,
);
expect(file.hunks[0].lines[0].tokens).toEqual([
{ text: "😀", style: "string" },
{ text: "é", style: null },
]);
});
test("preserves binary additions, modifications, deletions and mode-only text", async () => {
const { materializeDiffFile } = await import("./diff.js");
for (const change of ["added", "modified", "deleted"] as const) {
const entry = {
path: "image.png",
oldPath: null,
change,
content: "binary" as const,
additions: 0,
deletions: 0,
body: false,
};
const begin = {
opcode: 0x40 as const,
slot: 1,
revision: 1,
baseRevision: 0,
baseSha: "",
headSha: "",
mergeBaseSha: "",
entries: [entry],
};
expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin);
expect(materializeDiffFile(entry, null, false)).toEqual({
path: "image.png",
isNew: change === "added",
isDeleted: change === "deleted",
additions: 0,
deletions: 0,
status: "binary",
hunks: [],
});
}
expect(
materializeDiffFile(
{
path: "script",
oldPath: null,
change: "modified",
content: "text",
additions: 0,
deletions: 0,
body: true,
},
{ path: "script", hunks: [] },
false,
),
).toEqual({
path: "script",
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
status: "ok",
hunks: [],
});
});
test("rejects every truncated prefix, unknown version, trailing data, and invalid coverage", () => {
const end = {
opcode: 0x42 as const,
slot: 1,
revision: 9007199254740991,
diffTooLarge: true,
omitted: ["a.ts"],
error: { code: "REF_NOT_FOUND" as const, message: "missing" },
};
const bytes = encodeDiffFrame(end);
expect(decodeDiffFrame(bytes)).toEqual(end);
for (let length = 0; length < bytes.length; length++)
expect(() => decodeDiffFrame(bytes.slice(0, length))).toThrow();
const version = bytes.slice();
version[2] = 2;
expect(() => decodeDiffFrame(version)).toThrow("version");
expect(() => decodeDiffFrame(Uint8Array.from([...bytes, 0]))).toThrow("Trailing");
expect(() =>
encodeDiffFrame({
opcode: 0x41,
slot: 1,
revision: 1,
files: [
{
path: "x",
hunks: [
{
oldStart: 0,
oldCount: 0,
newStart: 1,
newCount: 1,
lines: [{ type: "add", content: "😀", runs: [{ style: null, length: 1 }] }],
},
],
},
],
}),
).toThrow("cover");
});
test("uses fixed UTF-8 byte vectors while runs count UTF-16 units", () => {
const bytes = Uint8Array.from([
65, 0, 1, 1, 1, 1, 120, 1, 0, 0, 1, 1, 1, 129, 7, 240, 159, 152, 128, 101, 204, 129, 1, 3, 4,
]);
const frame = {
opcode: 0x41 as const,
slot: 0,
revision: 1,
files: [
{
path: "x",
hunks: [
{
oldStart: 0,
oldCount: 0,
newStart: 1,
newCount: 1,
lines: [
{ type: "add" as const, content: "😀é", runs: [{ style: "string", length: 4 }] },
],
},
],
},
],
};
expect(encodeDiffFrame(frame)).toEqual(bytes);
expect(decodeDiffFrame(bytes)).toEqual(frame);
const unknownStyle = bytes.slice();
unknownStyle[23] = 255;
expect(decodeDiffFrame(unknownStyle)).toEqual({
...frame,
files: [
{
...frame.files[0],
hunks: [
{
...frame.files[0].hunks[0],
lines: [{ type: "add", content: "😀é", runs: [{ style: null, length: 4 }] }],
},
],
},
],
});
});
test("roundtrips generated entry lists across varint boundaries", () => {
for (const revision of [0, 1, 127, 128, 16383, 16384, 4294967296, Number.MAX_SAFE_INTEGER]) {
const entries = Array.from({ length: 130 }, (_, index) => ({
path: `src/${index}-😀.ts`,
oldPath: `old/${index}.ts`,
change: "renamed" as const,
content: "text" as const,
additions: index * 128,
deletions: index,
body: true,
}));
const begin = {
opcode: 0x40 as const,
slot: 255,
revision,
baseRevision: 0,
baseSha: "base",
headSha: "head",
mergeBaseSha: "merge",
entries,
};
expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin);
}
});
test.each([true, false])(
"preserves BOM-prefixed paths and source lines with runs=%s",
(highlight) => {
const frame = {
opcode: 0x41 as const,
slot: 0,
revision: 1,
files: [
{
path: "\uFEFFbom.ts",
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: (["add", "remove", "context"] as const).map((type) => ({
type,
content: "\uFEFFvalue",
runs: highlight ? [{ style: null, length: 6 }] : undefined,
})),
},
],
},
],
};
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
},
);
+360
View File
@@ -0,0 +1,360 @@
import type { ParsedDiffFile } from "../messages.js";
// Opcodes 0x40–0x4f are reserved for diff revisions. Plugins own 0x20–0x2f.
export const DiffOpcode = { Begin: 0x40, Bodies: 0x41, End: 0x42 } as const;
export const DIFF_HIGHLIGHT_STYLES = [
null,
"keyword",
"comment",
"string",
"number",
"literal",
"function",
"definition",
"class",
"type",
"tag",
"attribute",
"property",
"variable",
"operator",
"punctuation",
"regexp",
"escape",
"meta",
"heading",
"link",
] as const;
const CHANGES = ["added", "modified", "deleted", "renamed"] as const;
const CONTENTS = ["text", "binary"] as const;
const KINDS = ["context", "add", "remove", "header"] as const;
const ERRORS = [
null,
"NOT_GIT_REPO",
"REF_NOT_FOUND",
"INVALID_COMPARE",
"TOO_MANY_SUBSCRIPTIONS",
"UNKNOWN",
] as const;
export interface DiffEntry {
path: string;
oldPath: string | null;
change: (typeof CHANGES)[number];
content: (typeof CONTENTS)[number];
additions: number;
deletions: number;
body: boolean;
}
export interface DiffRun {
style: string | null;
length: number;
}
export interface DiffStreamLine {
type: (typeof KINDS)[number];
content: string;
runs?: DiffRun[];
}
export interface DiffStreamHunk {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: DiffStreamLine[];
}
export interface DiffBody {
path: string;
hunks: DiffStreamHunk[];
}
export interface DiffBegin {
opcode: typeof DiffOpcode.Begin;
slot: number;
revision: number;
baseRevision: number;
baseSha: string;
headSha: string;
mergeBaseSha: string;
entries: DiffEntry[];
}
export interface DiffBodies {
opcode: typeof DiffOpcode.Bodies;
slot: number;
revision: number;
files: DiffBody[];
}
export interface DiffEnd {
opcode: typeof DiffOpcode.End;
slot: number;
revision: number;
diffTooLarge: boolean;
omitted: string[];
error: { code: NonNullable<(typeof ERRORS)[number]>; message: string } | null;
}
export type DiffFrame = DiffBegin | DiffBodies | DiffEnd;
export class InvalidDiffFrameError extends Error {}
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
class Writer {
private bytes: number[] = [];
byte(value: number): void {
if (!Number.isInteger(value) || value < 0 || value > 255)
throw new InvalidDiffFrameError("Invalid byte");
this.bytes.push(value);
}
uint(value: number): void {
if (!Number.isSafeInteger(value) || value < 0)
throw new InvalidDiffFrameError("Invalid unsigned integer");
do {
const next = value % 128;
value = Math.floor(value / 128);
this.byte(next + (value ? 128 : 0));
} while (value);
}
str(value: string): void {
const bytes = textEncoder.encode(value);
this.uint(bytes.length);
for (const byte of bytes) this.byte(byte);
}
finish(): Uint8Array {
return Uint8Array.from(this.bytes);
}
}
class Reader {
private offset = 0;
constructor(private bytes: Uint8Array) {}
byte(): number {
if (this.offset >= this.bytes.length) throw new InvalidDiffFrameError("Truncated diff frame");
return this.bytes[this.offset++];
}
uint(): number {
let value = 0;
let factor = 1;
for (let index = 0; index < 8; index++) {
const byte = this.byte();
value += (byte & 127) * factor;
if (!Number.isSafeInteger(value)) throw new InvalidDiffFrameError("Integer overflow");
if (byte < 128) return value;
factor *= 128;
}
throw new InvalidDiffFrameError("Invalid varint");
}
count(): number {
const value = this.uint();
if (value > this.bytes.length - this.offset)
throw new InvalidDiffFrameError("Count exceeds frame");
return value;
}
str(): string {
const size = this.count();
const start = this.offset;
this.offset += size;
try {
return textDecoder.decode(this.bytes.subarray(start, this.offset));
} catch {
throw new InvalidDiffFrameError("Invalid UTF-8");
}
}
done(): void {
if (this.offset !== this.bytes.length) throw new InvalidDiffFrameError("Trailing bytes");
}
}
function enumAt<T>(values: readonly T[], index: number): T {
if (index >= values.length) throw new InvalidDiffFrameError("Unknown enum value");
return values[index];
}
function validateRuns(line: DiffStreamLine): void {
if (!line.runs) return;
let length = 0;
for (const run of line.runs) {
if (!Number.isSafeInteger(run.length) || run.length <= 0)
throw new InvalidDiffFrameError("Invalid run length");
length += run.length;
}
if (length !== line.content.length) throw new InvalidDiffFrameError("Runs do not cover line");
}
function writeLine(writer: Writer, line: DiffStreamLine): void {
validateRuns(line);
writer.byte(KINDS.indexOf(line.type) | (line.runs ? 128 : 0));
writer.str(line.content);
writer.uint(line.runs ? line.runs.length : 0);
for (const run of line.runs ?? []) {
const style = DIFF_HIGHLIGHT_STYLES.findIndex((value) => value === run.style);
writer.byte(Math.max(0, style));
writer.uint(run.length);
}
}
function readLine(reader: Reader): DiffStreamLine {
const flags = reader.byte();
const type = enumAt(KINDS, flags & 127);
const content = reader.str();
const count = reader.count();
const runs: DiffRun[] = [];
for (let index = 0; index < count; index++) {
const styleIndex = reader.byte();
const style = DIFF_HIGHLIGHT_STYLES[styleIndex] ?? null;
runs.push({ style, length: reader.uint() });
}
if (!(flags & 128)) {
if (count) throw new InvalidDiffFrameError("Runs without flag");
return { type, content };
}
const line = { type, content, runs };
validateRuns(line);
return line;
}
export function encodeDiffFrame(frame: DiffFrame): Uint8Array {
const writer = new Writer();
writer.byte(frame.opcode);
writer.byte(frame.slot);
writer.byte(1);
writer.uint(frame.revision);
if (frame.opcode === DiffOpcode.Begin) {
writer.uint(frame.baseRevision);
writer.str(frame.baseSha);
writer.str(frame.headSha);
writer.str(frame.mergeBaseSha);
writer.uint(frame.entries.length);
for (const entry of frame.entries) {
if (entry.content === "binary" && entry.body)
throw new InvalidDiffFrameError("Binary entry has a body");
writer.str(entry.path);
writer.str(entry.oldPath ?? "");
writer.byte(CHANGES.indexOf(entry.change));
writer.byte(CONTENTS.indexOf(entry.content));
writer.uint(entry.additions);
writer.uint(entry.deletions);
writer.byte(Number(entry.body));
}
} else if (frame.opcode === DiffOpcode.Bodies) {
writer.uint(frame.files.length);
for (const file of frame.files) {
writer.str(file.path);
writer.uint(file.hunks.length);
for (const hunk of file.hunks) {
writer.uint(hunk.oldStart);
writer.uint(hunk.oldCount);
writer.uint(hunk.newStart);
writer.uint(hunk.newCount);
writer.uint(hunk.lines.length);
for (const line of hunk.lines) writeLine(writer, line);
}
}
} else {
writer.byte(Number(frame.diffTooLarge));
writer.uint(frame.omitted.length);
for (const path of frame.omitted) writer.str(path);
writer.byte(frame.error ? ERRORS.indexOf(frame.error.code) : 0);
writer.str(frame.error ? frame.error.message : "");
}
return writer.finish();
}
export function decodeDiffFrame(bytes: Uint8Array): DiffFrame {
const reader = new Reader(bytes);
const opcode = reader.byte();
const slot = reader.byte();
if (reader.byte() !== 1) throw new InvalidDiffFrameError("Unknown diff version");
const revision = reader.uint();
let frame: DiffFrame;
if (opcode === DiffOpcode.Begin) {
const baseRevision = reader.uint();
const baseSha = reader.str();
const headSha = reader.str();
const mergeBaseSha = reader.str();
const count = reader.count();
const entries: DiffEntry[] = [];
for (let index = 0; index < count; index++) {
const path = reader.str();
const oldPath = reader.str() || null;
const change = enumAt(CHANGES, reader.byte());
const content = enumAt(CONTENTS, reader.byte());
const additions = reader.uint();
const deletions = reader.uint();
const bodyByte = reader.byte();
if (bodyByte > 1 || (content === "binary" && bodyByte))
throw new InvalidDiffFrameError("Invalid entry body");
entries.push({ path, oldPath, change, content, additions, deletions, body: bodyByte === 1 });
}
frame = { opcode, slot, revision, baseRevision, baseSha, headSha, mergeBaseSha, entries };
} else if (opcode === DiffOpcode.Bodies) {
const count = reader.count();
const files: DiffBody[] = [];
for (let index = 0; index < count; index++) {
const path = reader.str();
const hunkCount = reader.count();
const hunks: DiffStreamHunk[] = [];
for (let h = 0; h < hunkCount; h++) {
const oldStart = reader.uint();
const oldCount = reader.uint();
const newStart = reader.uint();
const newCount = reader.uint();
const lineCount = reader.count();
const lines: DiffStreamLine[] = [];
for (let l = 0; l < lineCount; l++) lines.push(readLine(reader));
hunks.push({ oldStart, oldCount, newStart, newCount, lines });
}
files.push({ path, hunks });
}
frame = { opcode, slot, revision, files };
} else if (opcode === DiffOpcode.End) {
const flags = reader.byte();
if (flags > 1) throw new InvalidDiffFrameError("Unknown flags");
const count = reader.count();
const omitted: string[] = [];
for (let index = 0; index < count; index++) omitted.push(reader.str());
const code = enumAt(ERRORS, reader.byte());
const message = reader.str();
frame = {
opcode,
slot,
revision,
diffTooLarge: flags === 1,
omitted,
error: code ? { code, message } : null,
};
} else {
throw new InvalidDiffFrameError("Unknown diff opcode");
}
reader.done();
return frame;
}
export function runsToTokens(
line: DiffStreamLine,
): NonNullable<ParsedDiffFile["hunks"][number]["lines"][number]["tokens"]> {
let offset = 0;
return (line.runs ?? []).map((run) => {
const text = line.content.slice(offset, offset + run.length);
offset += run.length;
return { text, style: run.style };
});
}
export function materializeDiffFile(
entry: DiffEntry,
body: DiffBody | null,
omitted: boolean,
): ParsedDiffFile {
let status: "ok" | "binary" | "too_large" = "ok";
if (entry.content === "binary") status = "binary";
else if (omitted) status = "too_large";
const hunks = body
? body.hunks.map((hunk) => ({
...hunk,
lines: hunk.lines.map((line) => {
if (line.runs)
return { type: line.type, content: line.content, tokens: runsToTokens(line) };
return { type: line.type, content: line.content };
}),
}))
: [];
const file: ParsedDiffFile = {
path: entry.path,
isNew: entry.change === "added",
isDeleted: entry.change === "deleted",
additions: entry.additions,
deletions: entry.deletions,
hunks,
status,
};
if (entry.oldPath !== null) file.oldPath = entry.oldPath;
return file;
}
@@ -3,3 +3,4 @@ export * from "./file-transfer.js";
export * from "./terminal.js";
export * from "./host-route.js";
export * from "./plugin.js";
export * from "./diff.js";
-9
View File
@@ -1,9 +0,0 @@
/** Remove the fork prompt envelope while preserving the snapshot body verbatim. */
export function extractChatHistoryBody(text: string): string {
const bodyStart = text.indexOf("\n\n");
return text.startsWith("<chat-history-summary>\n") &&
text.endsWith("\n</chat-history-summary>") &&
bodyStart !== -1
? text.slice(bodyStart + 2, -"\n</chat-history-summary>".length)
: text;
}
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
AgentTimelineItemPayloadSchema,
AgentForkContextRequestMessageSchema,
AgentForkContextResponseMessageSchema,
CreateAgentRequestMessageSchema,
@@ -10,24 +9,6 @@ import {
} from "./messages.js";
describe("shared messages attachments", () => {
it("round-trips fork history on a timeline row and accepts rows without it", () => {
const row = {
type: "user_message",
text: "",
messageId: "fork-prompt",
chatHistory: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Earlier context",
chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" },
},
};
expect(AgentTimelineItemPayloadSchema.parse(row)).toEqual(row);
const legacy = { type: "user_message", text: "Earlier message" };
expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy);
});
it("preserves an optional timeline cursor on fork-context messages", () => {
const boundaryCursor = { epoch: "timeline-1", seq: 42 };
const request = AgentForkContextRequestMessageSchema.parse({
@@ -288,29 +269,6 @@ describe("shared messages attachments", () => {
]);
});
it("preserves the source of a fork across message submission", () => {
const attachment = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Earlier context",
chatHistorySource: {
serverId: "source-host",
agentId: "source-agent",
title: "Original conversation",
},
};
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "req-fork-source",
agentId: "fork-agent",
text: "Continue",
attachments: [attachment],
});
expect(parsed.attachments).toEqual([attachment]);
});
it("keeps known text attachment context kinds and ignores future ones", () => {
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
@@ -0,0 +1,24 @@
import { expect, test } from "vitest";
import { DiffSubscribeRequestSchema, DiffSubscribeResponseSchema } from "./messages.js";
test("accepts explicit endpoints without changing legacy schemas", () => {
const request = {
type: "diff.subscribe.request",
requestId: "r",
cwd: "/repo",
base: { kind: "rev", rev: "v1" },
head: { kind: "worktree" },
mergeBase: true,
highlight: false,
};
expect(DiffSubscribeRequestSchema.parse(request)).toEqual(request);
expect(
DiffSubscribeRequestSchema.safeParse({ ...request, base: { kind: "unknown" } }).success,
).toBe(false);
expect(
DiffSubscribeResponseSchema.safeParse({
type: "diff.subscribe.response",
payload: { requestId: "r", subscriptionId: "s", slot: 256, error: null },
}).success,
).toBe(false);
});
+50 -23
View File
@@ -712,25 +712,6 @@ const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown
ToolCallCanceledPayloadSchema,
]);
export const ChatHistorySourceSchema = z.object({
serverId: z.string(),
agentId: z.string(),
title: z.string().optional(),
});
export type ChatHistorySource = z.infer<typeof ChatHistorySourceSchema>;
export const ChatHistoryAttachmentSchema = z.object({
type: z.literal("text"),
mimeType: z.literal("text/plain"),
contextKind: z.literal("chat_history"),
title: z.string().nullable().optional(),
text: z.string(),
chatHistorySource: ChatHistorySourceSchema.optional(),
});
export type ChatHistoryAttachment = z.infer<typeof ChatHistoryAttachmentSchema>;
// zod-aot 0.20.4 miscompiles this as a nested discriminated union by omitting
// the inner tool_call branch from the generated outer dispatch.
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknown> = z.union([
@@ -739,7 +720,6 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknow
text: z.string(),
messageId: z.string().optional(),
clientMessageId: z.string().optional(),
chatHistory: ChatHistoryAttachmentSchema.optional(),
}),
z.object({
type: z.literal("assistant_message"),
@@ -1200,7 +1180,6 @@ export const TextAttachmentSchema = z
title: z.string().nullable().optional(),
text: z.string(),
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
chatHistorySource: ChatHistorySourceSchema.optional(),
})
.transform(({ contextKind, ...attachment }) => ({
...attachment,
@@ -2309,6 +2288,51 @@ const CheckoutDiffCompareSchema = z.object({
ignoreWhitespace: z.boolean().optional(),
});
export const DiffEndpointSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("rev"), rev: z.string() }),
z.object({ kind: z.literal("index") }),
z.object({ kind: z.literal("worktree") }),
z.object({ kind: z.literal("empty") }),
]);
export const DiffErrorSchema = z.object({
code: z.enum([
"NOT_GIT_REPO",
"REF_NOT_FOUND",
"INVALID_COMPARE",
"TOO_MANY_SUBSCRIPTIONS",
"UNKNOWN",
]),
message: z.string(),
});
export const DiffSubscribeRequestSchema = z.object({
type: z.literal("diff.subscribe.request"),
requestId: z.string(),
cwd: z.string(),
base: DiffEndpointSchema,
head: DiffEndpointSchema,
mergeBase: z.boolean().optional(),
ignoreWhitespace: z.boolean().optional(),
highlight: z.boolean().optional(),
});
// Unsubscribe is fire-and-forget; release has no correlated response.
export const DiffUnsubscribeRequestSchema = z.object({
type: z.literal("diff.unsubscribe.request"),
subscriptionId: z.string(),
});
export const DiffSubscribeResponseSchema = z.object({
type: z.literal("diff.subscribe.response"),
payload: z.object({
requestId: z.string(),
subscriptionId: z.string(),
slot: z.number().int().min(0).max(255),
error: DiffErrorSchema.nullable(),
}),
});
export type DiffEndpoint = z.infer<typeof DiffEndpointSchema>;
export type DiffError = z.infer<typeof DiffErrorSchema>;
export type DiffSubscribeRequest = z.infer<typeof DiffSubscribeRequestSchema>;
export type DiffSubscribeResponse = z.infer<typeof DiffSubscribeResponseSchema>;
export const CheckoutStatusRequestSchema = z.object({
type: z.literal("checkout_status_request"),
cwd: z.string(),
@@ -3449,6 +3473,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
AgentDetachRequestMessageSchema,
AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema,
DiffSubscribeRequestSchema,
DiffUnsubscribeRequestSchema,
CheckoutStatusRequestSchema,
CheckoutDiffGetRequestSchema,
SubscribeCheckoutDiffRequestSchema,
@@ -3908,6 +3934,7 @@ export const ServerInfoStatusPayloadSchema = z
selectiveAgentTimeline: z.boolean().optional(),
explicitEventSubscriptions: z.boolean().optional(),
ownedSubscriptions: z.boolean().optional(),
diffSubscription: z.boolean().optional(),
binaryChunkingV1: z.boolean().optional(),
workspaceSetupObservation: z.boolean().optional(),
workspaceSortTimes: z.boolean().optional(),
@@ -4210,8 +4237,6 @@ export const WorkspaceGitHubRuntimePayloadSchema = z
export const WorkspaceDescriptorPayloadSchema = z
.object({
// Informational creation ancestry; independent of placement and lifecycle.
parentWorkspaceId: z.string().optional(),
id: z.string(),
projectId: z.string(),
projectDisplayName: z.string(),
@@ -5451,6 +5476,7 @@ const CheckoutStatusCommonSchema = z.object({
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
baseRefExact: z.string().nullable().optional(),
comparisonBaseRef: z.string().nullable().optional(),
// The full ref currentBranch tracks, as git resolves `<branch>@{upstream}`:
// "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a
// "refs/heads/..." ref for a branch tracking a local branch. Null when there is no
@@ -7258,6 +7284,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentDeletedMessageSchema,
AgentArchivedMessageSchema,
CloseItemsResponseSchema,
DiffSubscribeResponseSchema,
CheckoutStatusResponseSchema,
CheckoutStatusUpdateSchema,
CheckoutDiffGetResponseSchema,
@@ -634,26 +634,6 @@ describe("workspace message schemas", () => {
]);
});
test("preserves informational workspace parentage in directory updates", () => {
const workspace = {
id: "child",
parentWorkspaceId: "parent",
projectId: "project",
projectDisplayName: "repo",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "child",
status: "done",
activityAt: null,
};
expect(WorkspaceDescriptorPayloadSchema.parse(workspace).parentWorkspaceId).toBe("parent");
expect(
WorkspaceDescriptorPayloadSchema.parse({ ...workspace, parentWorkspaceId: undefined })
.parentWorkspaceId,
).toBeUndefined();
});
test("parses legacy workspace descriptors without workspaceDirectory", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",
+2 -1
View File
@@ -102,7 +102,8 @@
"test:e2e:real": "npm run test:integration:real",
"test:e2e:local": "npm run test:integration:local",
"test:e2e:relay:live": "cross-env RUN_LIVE_RELAY_E2E=1 vitest run src/server/daemon-e2e/live-relay.real.e2e.test.ts --maxWorkers=1",
"test:e2e:ui": "vitest --ui e2e.test.ts"
"test:e2e:ui": "vitest --ui e2e.test.ts",
"measure:diff-subscriptions": "tsx scripts/measure-diff-subscriptions.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
@@ -0,0 +1,111 @@
import { performance } from "node:perf_hooks";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
encodeDiffFrame,
DiffOpcode,
type DiffFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { DiffSubscriptions } from "../src/server/diff/index.js";
import { createFileObserver } from "../src/server/file-observer/index.js";
import { runGitCommand } from "../src/utils/run-git-command.js";
import { parseAndHighlightDiff } from "../src/server/utils/diff-highlighter.js";
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
const cwd = resolve(import.meta.dirname, "../../..");
const artifacts = resolve(cwd, ".artifacts/diff-subscription");
await mkdir(artifacts, { recursive: true });
const pinnedHead = "3ed57e9dd";
const observer = createFileObserver();
const engine = new DiffSubscriptions({
async requestWorkingTreeWatch(root, changed) {
const watch = await observer.subscribe(root, () => changed(), {
ignore: [resolve(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await watch.unsubscribe();
},
};
},
});
try {
for (const count of [1, 30]) {
const [base, head] = await Promise.all([
runGitCommand(["rev-parse", `${pinnedHead}~${count}`], { cwd }),
runGitCommand(["rev-parse", pinnedHead], { cwd }),
]);
const started = performance.now();
const frames: DiffFrame[] = [];
const encoded: Uint8Array[] = [];
const subscription = await engine.subscribe(
{
cwd,
base: { kind: "rev", rev: base.stdout.trim() },
head: { kind: "rev", rev: head.stdout.trim() },
},
async (frame) => {
frames.push(frame);
encoded.push(encodeDiffFrame(frame));
},
);
await subscription.start();
const coldMs = performance.now() - started;
await subscription.release();
const raw = await runGitCommand(["diff", base.stdout.trim(), head.stdout.trim()], {
cwd,
maxOutputBytes: 50 * 1024 * 1024,
});
const contentAt = async (sha: string, path: string) => {
const result = await runGitCommand(["show", `${sha}:${path}`], {
cwd,
acceptExitCodes: [0, 128],
});
return result.exitCode === 0 ? result.stdout : null;
};
const legacyFiles = await parseAndHighlightDiff(raw.stdout, cwd, {
getOldFileContent: (file) => contentAt(base.stdout.trim(), file.path),
getNewFileContent: (file) => contentAt(head.stdout.trim(), file.path),
});
const legacy = JSON.stringify({
type: "session",
message: {
type: "checkout_diff_update",
payload: { cwd, subscriptionId: "benchmark", files: legacyFiles, error: null },
},
});
const parseStart = performance.now();
const validation = validateWSOutboundMessage(JSON.parse(legacy));
if (!validation.success) throw validation.error;
const jsonParseValidationMs = performance.now() - parseStart;
const begin = frames.find((frame) => frame.opcode === DiffOpcode.Begin);
const end = frames.findLast((frame) => frame.opcode === DiffOpcode.End);
if (!begin || begin.opcode !== DiffOpcode.Begin || !end || end.opcode !== DiffOpcode.End)
throw new Error("Revision was not delimited");
if (end.error) throw new Error(end.error.message);
const metrics = {
pinnedHead: head.stdout.trim(),
count,
files: begin.entries.length,
rawPatchBytes: Buffer.byteLength(raw.stdout),
encodedBytes: encoded.reduce((sum, bytes) => sum + bytes.length, 0),
legacyJsonBytes: Buffer.byteLength(legacy),
coldMs,
jsonParseValidationMs,
omitted: end.omitted,
};
console.log(JSON.stringify(metrics));
await writeFile(
resolve(artifacts, `corpus-${count}.json`),
JSON.stringify({
metrics,
legacy,
frames: encoded.map((bytes) => Buffer.from(bytes).toString("base64")),
}),
);
}
} finally {
await engine.close();
await observer.close();
}
@@ -572,57 +572,3 @@ it("preserves every message in fork history below the character budget", () => {
`<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] ${text}\n</chat-history-summary>`,
);
});
it.each(["", "Continue in B"])("carries inherited history through repeated forks (%s)", (text) => {
const original = buildAgentForkContextAttachment({
rows: [
row(1, { type: "user_message", text: "Question in A" }),
row(2, { type: "assistant_message", text: "Answer in A" }),
],
});
const child = buildAgentForkContextAttachment({
rows: [
row(1, {
type: "user_message",
text,
chatHistory: { ...original.attachment, contextKind: "chat_history" },
}),
row(2, { type: "assistant_message", text: "Answer in B" }),
],
});
const grandchild = buildAgentForkContextAttachment({
rows: [
row(1, {
type: "user_message",
text: "Continue in C",
chatHistory: { ...child.attachment, contextKind: "chat_history" },
}),
row(2, { type: "assistant_message", text: "Answer in C" }),
],
});
expect(grandchild.attachment.text).toContain("[User] Question in A\n[Assistant] Answer in A");
expect(grandchild.attachment.text).toContain("[Assistant] Answer in B");
expect(grandchild.attachment.text).toContain("[User] Continue in C\n[Assistant] Answer in C");
expect(grandchild.attachment.text.match(/<chat-history-summary>/g)).toHaveLength(1);
});
it("keeps the fork budget when inherited history exceeds the remaining space", () => {
const result = buildAgentForkContextAttachment({
rows: [
row(1, {
type: "user_message",
text: "Current question",
chatHistory: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Old ".repeat(30_000),
},
}),
row(2, { type: "assistant_message", text: "Latest answer" }),
],
});
expect(result.attachment.text.length).toBeLessThanOrEqual(100_000);
expect(result.attachment.text).toContain("[Earlier chat history omitted]");
expect(result.attachment.text).toContain("[User] Current question\n[Assistant] Latest answer");
});
@@ -1,4 +1,3 @@
import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
@@ -22,7 +21,6 @@ const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
];
interface ActivityCuratorOptions {
includeChatHistory?: boolean;
maxItems?: number;
labelAssistantMessages?: boolean;
includeKinds?: readonly AgentTimelineItem["type"][];
@@ -39,17 +37,6 @@ function activityEntry(text: string): ActivityEntry {
return { text };
}
function appendUserEntry(
entries: ActivityEntry[],
item: Extract<AgentTimelineItem, { type: "user_message" }>,
options?: ActivityCuratorOptions,
) {
if (options?.includeChatHistory && item.chatHistory) {
entries.push(activityEntry(extractChatHistoryBody(item.chatHistory.text)));
}
entries.push(activityEntry(`[User] ${item.text.trim()}`));
}
function appendTextEntry(
entries: ActivityEntry[],
item: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
@@ -171,7 +158,7 @@ function curateProjectedActivityEntries(
switch (item.type) {
case "user_message":
appendUserEntry(entries, item, options);
entries.push(activityEntry(`[User] ${item.text.trim()}`));
break;
case "assistant_message":
case "reasoning":
@@ -374,7 +361,6 @@ export function buildAgentForkContextAttachment(input: {
});
const entries = curateProjectedActivityEntries(selected.items, {
maxItems: 0,
includeChatHistory: true,
labelAssistantMessages: true,
includeKinds: FORK_CONTEXT_KINDS,
includeExternalToolInput: false,
@@ -729,159 +729,6 @@ test("uses an injected timeline store without making it a production requirement
}
});
test.each([true, false])(
"fork history survives storage-backed replay (provider echo: %s)",
async (echo) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-fork-history-"));
const storagePath = join(workdir, "agents");
const attachment = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] Original question\n[Assistant] Original answer\n</chat-history-summary>",
chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" },
} as const;
const clientMessageId = "fork-prompt";
let hasHistory = false;
let includeFork = true;
class HistorySession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
hasHistory = true;
if (echo)
this.pushEvent({
type: "timeline",
provider: "codex",
item: {
type: "user_message",
text: attachment.text,
messageId: "native-fork",
clientMessageId,
},
});
return super.startTurn();
}
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (!hasHistory) return;
if (includeFork)
yield {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: attachment.text, messageId: "native-fork" },
};
yield {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: attachment.text, messageId: "native-later" },
};
}
}
class HistoryClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new HistorySession(config);
}
override async resumeSession(): Promise<AgentSession> {
return new HistorySession({ provider: "codex", cwd: workdir });
}
}
const client = new HistoryClient();
let storage = new AgentStorage(storagePath, logger);
let manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
onTestFinished(async () => {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
await drainAsyncGenerator(manager.streamAgent(agent.id, [attachment], { clientMessageId }));
expect((await manager.getTimelineRows(agent.id))[0]?.item).toMatchObject({
type: "user_message",
text: "",
chatHistory: attachment,
});
await manager.closeAgent(agent.id);
await manager.flush();
storage = new AgentStorage(storagePath, logger);
manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger });
await manager.hydrateTimelineFromProvider(agent.id);
expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([
{ type: "user_message", text: "", messageId: "native-fork", chatHistory: attachment },
{ type: "user_message", text: attachment.text, messageId: "native-later" },
]);
// A provider rewind must not recreate a removed fork prompt.
includeFork = false;
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([
{ type: "user_message", text: attachment.text, messageId: "native-later" },
]);
},
);
test.each([undefined, "native-replay-id"])(
"restores fork history after a local submission echo and replay ID %s",
async (replayId) => {
const workdir = mkdtempSync(join(tmpdir(), "fork-local-echo-"));
const clientMessageId = "local-fork-prompt";
const attachment = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Original question",
} as const;
class LocalEchoSession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
this.pushEvent({
type: "timeline",
provider: "codex",
item: {
type: "user_message",
text: attachment.text,
messageId: clientMessageId,
clientMessageId,
},
});
return super.startTurn();
}
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: "codex",
item: {
type: "user_message",
text: attachment.text,
...(replayId ? { messageId: replayId } : {}),
},
};
}
}
class LocalEchoClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new LocalEchoSession(config);
}
}
const manager = new AgentManager({ clients: { codex: new LocalEchoClient() }, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
await drainAsyncGenerator(manager.streamAgent(agent.id, [attachment], { clientMessageId }));
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
expect((await manager.getTimelineRows(agent.id))[0]?.item).toMatchObject({
type: "user_message",
text: "",
chatHistory: attachment,
});
} finally {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
}
},
);
test("refreshing an agent replaces the injected timeline store instead of appending a second copy", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rehydrate-"));
const store = new RecordingTimelineStore();
@@ -441,7 +441,6 @@ interface HandleStreamEventOptions {
}
interface ManagedAgentBase {
forkContext?: StoredAgentRecord["forkContext"];
id: string;
provider: AgentProvider;
cwd: string;
@@ -2702,15 +2701,11 @@ export class AgentManager {
if (event.type === "timeline") {
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agent.id, event.item);
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
return;
}
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
@@ -3924,8 +3919,6 @@ export class AgentManager {
options,
});
await this.restoreForkContext(managed);
// Read history before publishing the agent: a provider failure must leave the
// session unregistered so the registration catch closes it.
const startupHistory: AgentStreamEvent[] = [];
@@ -4498,15 +4491,11 @@ export class AgentManager {
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (broadcastTimeline) {
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
this.touchUpdatedAt(agent);
@@ -4571,17 +4560,13 @@ export class AgentManager {
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (deferredBroadcast) {
timelineEvents.push({ event: { ...event, item: row.item }, row });
timelineEvents.push({ event, row });
} else if (broadcast) {
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
agent.historyPrimed = true;
@@ -5116,7 +5101,7 @@ export class AgentManager {
const row = this.recordTimeline(agentId, item, { ...options, turnId });
const event: AgentStreamEvent = {
type: "timeline",
item: row.item,
item,
provider,
...(turnId !== undefined ? { turnId } : {}),
};
@@ -5148,25 +5133,9 @@ export class AgentManager {
outcome: "accepted" | "rejected",
options?: { messageId?: string; providerMessageId?: string; turnId?: string },
): void {
const text = submittedPromptText(prompt);
if (typeof prompt !== "string") {
const attachment = prompt.find(
(block) =>
block.type === "text" && "contextKind" in block && block.contextKind === "chat_history",
);
if (attachment?.type === "text" && "mimeType" in attachment) {
agent.forkContext = {
clientMessageId,
providerMessageId:
options?.providerMessageId !== clientMessageId ? options?.providerMessageId : undefined,
text,
attachment: { ...attachment, contextKind: "chat_history" },
};
}
}
const items = projectAgentMessages({
type: "user_message",
text,
text: submittedPromptText(prompt),
clientMessageId,
...(options?.messageId ? { messageId: options.messageId } : {}),
});
@@ -5214,10 +5183,6 @@ export class AgentManager {
}
if (!existing || existing.item.type !== "user_message") return null;
if (messageId) {
if (agent.forkContext?.clientMessageId === clientMessageId && messageId !== clientMessageId) {
agent.forkContext.providerMessageId = messageId;
this.enqueueBackgroundPersist(agent);
}
const enriched = this.timelineStore.enrichSubmittedUserMessage(
agent.id,
clientMessageId,
@@ -5282,10 +5247,6 @@ export class AgentManager {
return parts.join("\n\n");
}
private async restoreForkContext(agent: ManagedAgent): Promise<void> {
agent.forkContext = (await this.registry?.get(agent.id))?.forkContext;
}
private recordTimeline(
agentId: string,
item: AgentTimelineItem,
@@ -5295,29 +5256,6 @@ export class AgentManager {
turnId?: string;
},
): AgentTimelineRow {
const agent = this.agents.get(agentId);
const context = agent?.forkContext;
if (
context &&
item.type === "user_message" &&
(item.clientMessageId === context.clientMessageId ||
(context.providerMessageId
? item.messageId === context.providerMessageId
: context.attachment.text.length > 0 && item.text.includes(context.attachment.text)))
) {
// Providers without client IDs can be matched once by the exact snapshot.
// Remember their native ID so a later quotation is not mistaken for the fork.
if (
agent &&
!context.providerMessageId &&
item.messageId &&
item.messageId !== context.clientMessageId
) {
context.providerMessageId = item.messageId;
this.enqueueBackgroundPersist(agent);
}
item = { ...item, text: context.text, chatHistory: context.attachment };
}
item = limitAgentTimelineItemContent(item);
const row = this.timelineStore.append(agentId, item, options);
this.enqueueDurableTimelineAppend(agentId, row);
@@ -92,7 +92,6 @@ export function toStoredAgentRecord(
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
title: options?.title ?? null,
labels: agent.labels,
forkContext: agent.forkContext,
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
@@ -9,7 +9,7 @@ import type {
ToolPolicy,
} from "@getpaseo/protocol/agent-types";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment, ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
export type {
@@ -398,13 +398,7 @@ export interface PluginTimelineItem {
}
export type AgentTimelineItem =
| {
type: "user_message";
text: string;
messageId?: string;
clientMessageId?: string;
chatHistory?: ChatHistoryAttachment;
}
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
@@ -4,7 +4,7 @@ import { z } from "zod";
import type { Logger } from "pino";
import { writeJsonFileAtomic } from "../atomic-file.js";
import { AgentFeatureSchema, AgentStatusSchema, ChatHistoryAttachmentSchema } from "../messages.js";
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
@@ -53,15 +53,6 @@ const STORED_AGENT_SCHEMA = z.object({
lastUserMessageAt: z.string().nullable().optional(),
title: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
// Provider transcripts retain the rendered prompt, not attachment metadata.
forkContext: z
.object({
clientMessageId: z.string(),
providerMessageId: z.string().optional(),
text: z.string(),
attachment: ChatHistoryAttachmentSchema,
})
.optional(),
lastStatus: AgentStatusSchema.default("closed"),
resumeOnStartup: z.boolean().optional(),
lastModeId: z.string().nullable().optional(),
@@ -1171,7 +1171,7 @@ describe("Suite E: Workspace Tools", () => {
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
}, 30_000);
});
test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => {
const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", {
@@ -1197,43 +1197,12 @@ describe("Suite E: Workspace Tools", () => {
});
ownedWorkspaces.push(str(inherited.workspaceId));
expect(inherited.background).toBe(true);
expect(inherited.parentWorkspaceId).toBe(parentWorkspaceId);
const visible = await callToolStructured(scoped, "create_workspace", {
isolation: "local",
background: false,
});
ownedWorkspaces.push(str(visible.workspaceId));
expect(visible.background).toBe(false);
expect(visible.parentWorkspaceId).toBe(parentWorkspaceId);
const worktree = await callToolStructured(scoped, "create_workspace", {
isolation: "worktree",
branchName: "parentage-child",
baseBranch: "main",
background: false,
});
ownedWorkspaces.push(str(worktree.workspaceId));
expect(worktree.parentWorkspaceId).toBe(parentWorkspaceId);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemonHandle.port}/ws` });
try {
await client.connect();
const created = await client.createWorkspace({
source: { kind: "directory", path: worktreeRepoCwd },
callerAgentId: str(parent.agentId),
background: false,
});
const workspaceId = str(created.workspace?.id);
ownedWorkspaces.push(workspaceId);
expect(created.workspace?.parentWorkspaceId).toBe(parentWorkspaceId);
const listed = await client.fetchWorkspaces();
expect(listed.entries).toContainEqual(
expect.objectContaining({
id: workspaceId,
parentWorkspaceId: parentWorkspaceId,
}),
);
} finally {
await client.close();
}
} finally {
await scoped?.close();
for (const workspaceId of ownedWorkspaces.toReversed())
@@ -1241,7 +1210,7 @@ describe("Suite E: Workspace Tools", () => {
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
}, 30_000);
});
test("a background agent's children and workspaces are background unless it overrides", async () => {
const visibleAgentId = await createTopLevelAgent();
@@ -188,7 +188,6 @@ interface ProviderSummary {
}
const WorkspaceAutomationSummarySchema = z.object({
parentWorkspaceId: z.string().optional(),
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
@@ -207,7 +206,6 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) {
kind: workspace.kind,
title: workspace.title,
background: workspace.background,
parentWorkspaceId: workspace.parentWorkspaceId,
};
}
@@ -194,6 +194,8 @@ const INBOUND_PERMISSION = {
stash_pop_request: "workspace.write",
stash_save_request: "workspace.write",
"checkout.diff.get.request": "workspace.read",
"diff.subscribe.request": "workspace.read",
"diff.unsubscribe.request": "workspace.read",
subscribe_checkout_diff_request: "workspace.read",
subscribe_terminal_request: "workspace.read",
subscribe_terminals_request: "workspace.read",
@@ -437,6 +439,7 @@ const OUTBOUND_PERMISSION = {
stash_save_response: "workspace.read",
status: "daemon.read",
"checkout.diff.get.response": "workspace.read",
"diff.subscribe.response": "workspace.read",
subscribe_checkout_diff_response: "workspace.read",
subscribe_terminal_response: "workspace.read",
"terminal.rename.response": "workspace.write",

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