mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850471276a | ||
|
|
a5c5ad8e1d | ||
|
|
ad21f9eb93 | ||
|
|
8b5527e7e6 | ||
|
|
04a7d1b3a7 | ||
|
|
e044c788b5 | ||
|
|
986ce285b0 | ||
|
|
e516961a09 | ||
|
|
33db25cf5c | ||
|
|
0a9e678286 | ||
|
|
cee05e3c6c | ||
|
|
92be3e9dff | ||
|
|
8e1e377120 | ||
|
|
870180cbe5 | ||
|
|
567c1617f5 | ||
|
|
236270768d | ||
|
|
1d8eacd38a | ||
|
|
8bc533279a |
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
||||
import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
||||
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
@@ -144,10 +144,21 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
["status", "providers_snapshot_update"],
|
||||
() => {},
|
||||
),
|
||||
observeCheckoutDiff(cwd, compare) {
|
||||
observeDiff(input) {
|
||||
const cwd = input.cwd;
|
||||
let mode: "uncommitted" | "base" | "all" = "uncommitted";
|
||||
if (input.head.kind === "rev") mode = "base";
|
||||
else if (input.mergeBase) mode = "all";
|
||||
const compare = {
|
||||
mode,
|
||||
...(input.base.kind === "rev" && input.base.rev !== "HEAD"
|
||||
? { baseRef: input.base.rev }
|
||||
: {}),
|
||||
ignoreWhitespace: input.ignoreWhitespace,
|
||||
};
|
||||
const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`;
|
||||
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
|
||||
const handle = observe(
|
||||
const handle = observe<SubscribeCheckoutDiffResponseMessage["payload"]>(
|
||||
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
|
||||
["checkout_diff_update", "subscribe_checkout_diff_response"],
|
||||
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
|
||||
@@ -155,9 +166,43 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
"subscriptionId" in message.payload &&
|
||||
message.payload.subscriptionId === subscriptionId,
|
||||
);
|
||||
if (!config.rejectCheckoutDiffSubscribe) return handle;
|
||||
return {
|
||||
const modern: OwnedSubscription<DiffState> = {
|
||||
...handle,
|
||||
ready: Promise.resolve({
|
||||
phase: "ready",
|
||||
revision: 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: [],
|
||||
diffTooLarge: false,
|
||||
subscriptionId,
|
||||
}),
|
||||
subscribe: (observer) =>
|
||||
handle.subscribe({
|
||||
snapshot: (payload) =>
|
||||
observer.snapshot({
|
||||
phase: "ready",
|
||||
revision: 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: payload.files,
|
||||
diffTooLarge: payload.diffTooLarge === true,
|
||||
subscriptionId,
|
||||
}),
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update")
|
||||
observer.snapshot({
|
||||
phase: "ready",
|
||||
revision: 2,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: message.payload.files,
|
||||
diffTooLarge: message.payload.diffTooLarge === true,
|
||||
subscriptionId,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
if (!config.rejectCheckoutDiffSubscribe) return modern;
|
||||
return {
|
||||
...modern,
|
||||
subscribe: (observer) => {
|
||||
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
|
||||
return () => {};
|
||||
@@ -328,7 +373,7 @@ describe("server data push router", () => {
|
||||
const first = publish(files, "first");
|
||||
const same = publish(structuredClone(files), "reopen");
|
||||
expect(same.files).toBe(first.files);
|
||||
expect(same.requestId).toBe("reopen");
|
||||
expect(same.requestId).toBe("diff:1:ready");
|
||||
const changed = structuredClone(files);
|
||||
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
||||
const next = publish(changed, "edit");
|
||||
@@ -430,7 +475,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: "diff-1",
|
||||
requestId: "diff:1:ready",
|
||||
});
|
||||
|
||||
fake.emit({
|
||||
@@ -449,7 +494,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: `subscription:${serverSubscriptionId}`,
|
||||
requestId: "diff:2:ready",
|
||||
});
|
||||
|
||||
unsubscribeObserver();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { checkoutDiffSnapshot, type CheckoutDiffSnapshot } from "@/git/diff-snapshot";
|
||||
import type { DiffState } from "@getpaseo/client";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
@@ -23,14 +25,9 @@ type ProvidersSnapshotUpdateMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "providers_snapshot_update" }
|
||||
>;
|
||||
type SubscribeCheckoutDiffResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "subscribe_checkout_diff_response" }
|
||||
>;
|
||||
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
||||
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
||||
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
|
||||
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
interface CheckoutDiffCompare {
|
||||
@@ -49,7 +46,7 @@ interface CheckoutDiffRoute {
|
||||
}
|
||||
|
||||
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
|
||||
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
|
||||
subscription: OwnedSubscription<DiffState>;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalsRoute {
|
||||
@@ -75,7 +72,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
|
||||
interface ServerDataPushClient {
|
||||
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
|
||||
observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"];
|
||||
observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"];
|
||||
observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"];
|
||||
observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"];
|
||||
}
|
||||
|
||||
@@ -339,34 +336,34 @@ function reconcileCheckoutDiffSubscriptions(input: {
|
||||
}
|
||||
for (const [key, desired] of input.desired) {
|
||||
if (input.active.has(key)) continue;
|
||||
const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare);
|
||||
const { mode, baseRef, ignoreWhitespace } = desired.compare;
|
||||
const revision = mode === "uncommitted" ? "HEAD" : baseRef;
|
||||
if (!revision) continue;
|
||||
const subscription = input.client.observeDiff({
|
||||
cwd: desired.cwd,
|
||||
base: { kind: "rev", rev: revision },
|
||||
head: mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" },
|
||||
mergeBase: mode !== "uncommitted",
|
||||
ignoreWhitespace,
|
||||
});
|
||||
const registration = { ...desired, subscription };
|
||||
input.active.set(key, registration);
|
||||
const apply = (
|
||||
payload: Omit<CheckoutDiffResponsePayload, "requestId"> & { requestId?: string },
|
||||
) => {
|
||||
if (input.active.get(key) !== registration) return;
|
||||
const { subscriptionId, ...snapshot } = payload;
|
||||
setCheckoutDiffPayload({
|
||||
activeCheckoutDiffSubscriptions: input.active,
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
subscriptionId: key,
|
||||
payload: {
|
||||
...snapshot,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
requestId: payload.requestId ?? `subscription:${subscriptionId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
subscription.subscribe({
|
||||
snapshot: apply,
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") apply(message.payload);
|
||||
snapshot: (state) => {
|
||||
if (input.active.get(key) !== registration) return;
|
||||
const snapshot = checkoutDiffSnapshot(desired.cwd, state);
|
||||
setCheckoutDiffPayload({
|
||||
activeCheckoutDiffSubscriptions: input.active,
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
subscriptionId: key,
|
||||
payload: { ...snapshot, files: orderCheckoutDiffFiles(snapshot.files) },
|
||||
});
|
||||
},
|
||||
update: () => {},
|
||||
error: (error) => {
|
||||
if (input.active.get(key) === registration) input.active.delete(key);
|
||||
console.error("[server-data] observeCheckoutDiff failed", {
|
||||
console.error("[server-data] observeDiff failed", {
|
||||
serverId: input.serverId,
|
||||
cwd: desired.cwd,
|
||||
error,
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
type DiffTreeRow,
|
||||
} from "@/git/diff-tree";
|
||||
import { FileHeader } from "@/git/file-header";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
|
||||
export interface ChangedFilesTreeProps {
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
collapsedFolderPaths: string[];
|
||||
|
||||
@@ -13,6 +13,6 @@ export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[
|
||||
// chains, so it is skipped; the collapsed set is empty because the flat list
|
||||
// always carries every file, whatever the rail has collapsed.
|
||||
return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) =>
|
||||
row.kind === "file" ? [row.file] : [],
|
||||
row.kind === "file" ? [files[row.fileIndex]] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
|
||||
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
@@ -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}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { replaceEqualDeep } from "@tanstack/react-query";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import type { CheckoutDiffSnapshot } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
|
||||
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
type DiffPayload = CheckoutDiffSnapshot;
|
||||
|
||||
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
|
||||
export function retainDiffFiles(
|
||||
@@ -30,6 +31,7 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
|
||||
error === old.error &&
|
||||
old.cwd === next.cwd &&
|
||||
old.requestId === next.requestId &&
|
||||
old.entries === next.entries &&
|
||||
old.diffTooLarge === next.diffTooLarge
|
||||
? old
|
||||
: { ...next, files, error };
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { DiffState, DiffEntry } from "@getpaseo/client";
|
||||
import type {
|
||||
DiffError,
|
||||
ParsedDiffFile,
|
||||
SubscribeCheckoutDiffRequest,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
export type DiffFileHeader = Omit<ParsedDiffFile, "hunks">;
|
||||
export interface CheckoutDiffSnapshot {
|
||||
requestId: string;
|
||||
cwd: string;
|
||||
files: ParsedDiffFile[];
|
||||
entries?: DiffEntry[];
|
||||
error: DiffError | null;
|
||||
diffTooLarge?: boolean;
|
||||
}
|
||||
export function checkoutDiffSnapshot(cwd: string, state: DiffState): CheckoutDiffSnapshot {
|
||||
const common = { cwd, requestId: `diff:${state.revision}:${state.phase}` };
|
||||
if (state.phase === "loading")
|
||||
return { ...common, files: [], entries: state.entries, error: null };
|
||||
if (state.phase === "error") return { ...common, files: [], error: state.error };
|
||||
return { ...common, files: state.files, error: null, diffTooLarge: state.diffTooLarge };
|
||||
}
|
||||
export function diffEntryHeaders(entries: DiffEntry[]): DiffFileHeader[] {
|
||||
return entries.map((entry) => ({
|
||||
path: entry.path,
|
||||
...(entry.oldPath === null ? {} : { oldPath: entry.oldPath }),
|
||||
isNew: entry.change === "added",
|
||||
isDeleted: entry.change === "deleted",
|
||||
additions: entry.additions,
|
||||
deletions: entry.deletions,
|
||||
status: entry.content === "binary" ? "binary" : "ok",
|
||||
}));
|
||||
}
|
||||
export interface CheckoutDiffResult {
|
||||
files: ParsedDiffFile[];
|
||||
entries: DiffEntry[] | null;
|
||||
payloadError: CheckoutDiffSnapshot["error"];
|
||||
diffTooLarge: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result derives from the payload alone. Until a payload arrives there is no
|
||||
* diff to describe, so the boundary reports loading rather than an empty diff —
|
||||
* whether the query is idle (an inactive retained panel), in flight, or the host
|
||||
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
|
||||
* and "No changes" for a diff nobody has fetched yet.
|
||||
*/
|
||||
export function deriveCheckoutDiffResult(payload: CheckoutDiffSnapshot | null): CheckoutDiffResult {
|
||||
const payloadError = payload?.error ?? null;
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
entries: payload?.entries ?? null,
|
||||
payloadError,
|
||||
diffTooLarge: payload?.diffTooLarge === true,
|
||||
isLoading: payload === null || payload.entries !== undefined,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkoutDiffComparisonError(
|
||||
compare: Pick<SubscribeCheckoutDiffRequest["compare"], "mode" | "baseRef">,
|
||||
): DiffError | null {
|
||||
if (compare.mode === "uncommitted" || compare.baseRef?.trim()) return null;
|
||||
return {
|
||||
code: "INVALID_COMPARE",
|
||||
message: "Comparison base is unavailable. Select Uncommitted to view pending changes.",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
|
||||
// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view
|
||||
// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view
|
||||
// renders. The tree renders on every form factor, consistent with the Files
|
||||
// explorer.
|
||||
//
|
||||
@@ -17,7 +17,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
|
||||
export interface DiffTreeFileNode {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
fileIndex: number;
|
||||
/** basename, e.g. "diff-pane.tsx" */
|
||||
name: string;
|
||||
@@ -47,7 +47,7 @@ export interface DiffTreeFolderRow {
|
||||
|
||||
export interface DiffTreeFileRow {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
fileIndex: number;
|
||||
depth: number;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void {
|
||||
}
|
||||
|
||||
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
|
||||
export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
|
||||
export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode {
|
||||
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
|
||||
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ import {
|
||||
fileNameForPath,
|
||||
formatDiffCount,
|
||||
} from "@/git/file-header-presentation";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
export interface FileHeaderProps {
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
bodyVisible: boolean;
|
||||
showsBodyState?: boolean;
|
||||
@@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
|
||||
return showsBodyState ? bodyVisible : undefined;
|
||||
}
|
||||
|
||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
|
||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) {
|
||||
if (!canvasRendered) return undefined;
|
||||
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
|
||||
];
|
||||
}
|
||||
|
||||
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
|
||||
function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" {
|
||||
return diffFileChangeKind(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { deriveCheckoutDiffResult } from "./use-diff-query";
|
||||
import { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
|
||||
function createParsedDiffFile(path: string): ParsedDiffFile {
|
||||
return {
|
||||
@@ -59,6 +59,7 @@ describe("deriveCheckoutDiffResult", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
files: [],
|
||||
entries: null,
|
||||
payloadError: error,
|
||||
diffTooLarge: true,
|
||||
isLoading: false,
|
||||
@@ -68,3 +69,27 @@ describe("deriveCheckoutDiffResult", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the announced tree while file bodies are still loading", () => {
|
||||
const entries = [
|
||||
{
|
||||
path: "src/app.ts",
|
||||
oldPath: null,
|
||||
change: "modified",
|
||||
content: "text",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
},
|
||||
] as const;
|
||||
const result = deriveCheckoutDiffResult({ ...createPayload([]), entries: [...entries] });
|
||||
expect(result.isLoading).toBe(true);
|
||||
expect(result.entries).toEqual(entries);
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles a comparison without a base and leaves Uncommitted available", async () => {
|
||||
const { checkoutDiffComparisonError } = await import("./diff-snapshot");
|
||||
expect(checkoutDiffComparisonError({ mode: "all" })).toMatchObject({ code: "INVALID_COMPARE" });
|
||||
expect(checkoutDiffComparisonError({ mode: "base" })).toMatchObject({ code: "INVALID_COMPARE" });
|
||||
expect(checkoutDiffComparisonError({ mode: "uncommitted" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,13 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { checkoutDiffPushRoute } from "@/data/push-router";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
checkoutDiffComparisonError,
|
||||
deriveCheckoutDiffResult,
|
||||
type CheckoutDiffSnapshot,
|
||||
} from "./diff-snapshot";
|
||||
export { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -18,7 +24,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
queryScope?: string;
|
||||
}
|
||||
|
||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
type CheckoutDiffQueryPayload = CheckoutDiffSnapshot;
|
||||
|
||||
// Re-export the canonical protocol type so all consumers share one definition.
|
||||
export type { ParsedDiffFile };
|
||||
@@ -77,7 +83,11 @@ export function useCheckoutDiffQuery({
|
||||
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
||||
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
||||
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
|
||||
const comparisonError =
|
||||
queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null;
|
||||
const routeEnabled = Boolean(
|
||||
queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError,
|
||||
);
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
@@ -97,46 +107,13 @@ export function useCheckoutDiffQuery({
|
||||
}),
|
||||
});
|
||||
|
||||
return deriveCheckoutDiffResult(
|
||||
const boundaryError =
|
||||
requiresUpdate && isConnected
|
||||
? {
|
||||
requestId: "unsupported",
|
||||
cwd,
|
||||
files: [],
|
||||
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
|
||||
}
|
||||
? { code: "UNKNOWN" as const, message: "Update the host to view All changes" }
|
||||
: comparisonError;
|
||||
return deriveCheckoutDiffResult(
|
||||
boundaryError
|
||||
? { requestId: "unavailable", cwd, files: [], error: boundaryError }
|
||||
: (query.data ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
export interface CheckoutDiffResult {
|
||||
files: ParsedDiffFile[];
|
||||
payloadError: CheckoutDiffQueryPayload["error"];
|
||||
diffTooLarge: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result derives from the payload alone. Until a payload arrives there is no
|
||||
* diff to describe, so the boundary reports loading rather than an empty diff —
|
||||
* whether the query is idle (an inactive retained panel), in flight, or the host
|
||||
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
|
||||
* and "No changes" for a diff nobody has fetched yet.
|
||||
*/
|
||||
export function deriveCheckoutDiffResult(
|
||||
payload: CheckoutDiffQueryPayload | null,
|
||||
): CheckoutDiffResult {
|
||||
const payloadError = payload?.error ?? null;
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
diffTooLarge: payload?.diffTooLarge === true,
|
||||
isLoading: payload === null,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useReviewAttachmentSnapshot,
|
||||
useReviewFeedback,
|
||||
} from "@/review";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { diffEntryHeaders } from "./diff-snapshot";
|
||||
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
|
||||
@@ -44,7 +46,11 @@ export function useWorkingDiff({
|
||||
const statusErrorMessage =
|
||||
status?.error?.message ??
|
||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
const modernDiff = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.diffSubscription === true,
|
||||
);
|
||||
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
|
||||
const baseRef = (modernDiff ? gitStatus?.comparisonBaseRef : gitStatus?.baseRef) ?? undefined;
|
||||
const currentBranchName =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||
|
||||
@@ -59,6 +65,7 @@ export function useWorkingDiff({
|
||||
|
||||
const {
|
||||
files,
|
||||
entries,
|
||||
payloadError: diffPayloadError,
|
||||
diffTooLarge,
|
||||
isLoading: isDiffLoading,
|
||||
@@ -71,6 +78,7 @@ export function useWorkingDiff({
|
||||
enabled: enabled && isGit,
|
||||
queryScope,
|
||||
});
|
||||
const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]);
|
||||
const reviewDraftKey = useMemo(
|
||||
() =>
|
||||
buildReviewDraftKey({
|
||||
@@ -112,6 +120,7 @@ export function useWorkingDiff({
|
||||
selectBase,
|
||||
selectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "الاستخدام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "使用状況",
|
||||
|
||||
@@ -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: "사용량",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Использование",
|
||||
|
||||
@@ -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: "使用情况",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { DaemonClient, type DaemonTransport } from "../daemon-client.js";
|
||||
import {
|
||||
encodeDiffFrame,
|
||||
DiffOpcode,
|
||||
type DiffFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { DiffState } from "./index.js";
|
||||
function socket() {
|
||||
let message: (data: unknown, binary?: boolean) => void = () => {};
|
||||
let open: () => void = () => {};
|
||||
let close: (event?: unknown) => void = () => {};
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
const transport: DaemonTransport = {
|
||||
send: (data) => {
|
||||
if (typeof data === "string") {
|
||||
const value = JSON.parse(data);
|
||||
if (value.type === "session") {
|
||||
sent.push(value.message);
|
||||
if (value.message.type === "subscription.release.request")
|
||||
queueMicrotask(() =>
|
||||
receive({
|
||||
type: "subscription.release.response",
|
||||
payload: {
|
||||
requestId: value.message.requestId,
|
||||
subscriptionId: value.message.subscriptionId,
|
||||
released: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
close: () => {},
|
||||
onMessage: (handler) => {
|
||||
message = handler;
|
||||
return () => {};
|
||||
},
|
||||
onOpen: (handler) => {
|
||||
open = handler;
|
||||
return () => {};
|
||||
},
|
||||
onClose: (handler) => {
|
||||
close = handler;
|
||||
return () => {};
|
||||
},
|
||||
onError: () => () => {},
|
||||
};
|
||||
const receive = (value: unknown) =>
|
||||
message(JSON.stringify({ type: "session", message: value }), false);
|
||||
return {
|
||||
transport,
|
||||
sent,
|
||||
receive,
|
||||
raw: (bytes: Uint8Array) => message(bytes, true),
|
||||
frame: (frame: DiffFrame) => message(encodeDiffFrame(frame), true),
|
||||
disconnect: () => close({ code: 1001 }),
|
||||
open: (modern = true) => {
|
||||
open();
|
||||
receive({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { ownedSubscriptions: true, diffSubscription: modern, checkoutDiffAll: true },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
const compare = {
|
||||
cwd: "/repo",
|
||||
base: { kind: "rev", rev: "HEAD" },
|
||||
head: { kind: "worktree" },
|
||||
} as const;
|
||||
const begin: DiffFrame = {
|
||||
opcode: DiffOpcode.Begin,
|
||||
slot: 3,
|
||||
revision: 1,
|
||||
baseRevision: 0,
|
||||
baseSha: "a",
|
||||
headSha: "b",
|
||||
mergeBaseSha: "",
|
||||
entries: [],
|
||||
};
|
||||
const end: DiffFrame = {
|
||||
opcode: DiffOpcode.End,
|
||||
slot: 3,
|
||||
revision: 1,
|
||||
diffTooLarge: false,
|
||||
omitted: [],
|
||||
error: null,
|
||||
};
|
||||
async function setup(modern = true) {
|
||||
const wire = socket();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "diff-test",
|
||||
transportFactory: () => wire.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const connecting = client.connect();
|
||||
wire.open(modern);
|
||||
await connecting;
|
||||
return { wire, client };
|
||||
}
|
||||
function acknowledge(wire: ReturnType<typeof socket>, ordinal = 1) {
|
||||
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
|
||||
wire.receive({
|
||||
type: "diff.subscribe.response",
|
||||
payload: {
|
||||
requestId: request?.requestId,
|
||||
subscriptionId: `diff-${ordinal}`,
|
||||
slot: 3,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
test("attaches binary routing during handshake and resubscribes with a full state on reconnect", async () => {
|
||||
const { wire, client } = await setup();
|
||||
try {
|
||||
const states: DiffState[] = [];
|
||||
const observation = client.observeDiff(compare);
|
||||
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
|
||||
acknowledge(wire);
|
||||
wire.frame(begin);
|
||||
wire.frame(end);
|
||||
expect(states.map((state) => state.phase)).toEqual(["loading", "ready"]);
|
||||
wire.disconnect();
|
||||
const connected = client.connect();
|
||||
wire.open();
|
||||
await connected;
|
||||
acknowledge(wire, 2);
|
||||
wire.frame(begin);
|
||||
wire.frame(end);
|
||||
expect(states.map((state) => state.phase)).toEqual(["loading", "ready", "loading", "ready"]);
|
||||
await observation.release();
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
test("an unknown version fails only its subscription and readDiff resolves typed subscribe errors", async () => {
|
||||
const { wire, client } = await setup();
|
||||
try {
|
||||
const observation = client.observeDiff(compare);
|
||||
const states: DiffState[] = [];
|
||||
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
|
||||
acknowledge(wire);
|
||||
const malformed = { ...begin, slot: 3 };
|
||||
const bytes = encodeDiffFrame(malformed);
|
||||
bytes[2] = 255;
|
||||
wire.raw(bytes);
|
||||
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "UNKNOWN" } });
|
||||
const next = client.readDiff(compare);
|
||||
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
|
||||
wire.receive({
|
||||
type: "diff.subscribe.response",
|
||||
payload: {
|
||||
requestId: request?.requestId,
|
||||
subscriptionId: "",
|
||||
slot: 0,
|
||||
error: { code: "INVALID_COMPARE", message: "invalid" },
|
||||
},
|
||||
});
|
||||
expect(await next).toMatchObject({ phase: "error", error: { code: "INVALID_COMPARE" } });
|
||||
expect(client.isConnected).toBe(true);
|
||||
await observation.release();
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
test.each(["uncommitted", "base", "all"] as const)(
|
||||
"old hosts serve the %s comparison through the tagged compatibility route",
|
||||
async (mode) => {
|
||||
const { wire, client } = await setup(false);
|
||||
try {
|
||||
const comparison =
|
||||
mode === "uncommitted"
|
||||
? compare
|
||||
: {
|
||||
...compare,
|
||||
base: { kind: "rev", rev: "refs/tags/v1" } as const,
|
||||
head: mode === "base" ? ({ kind: "rev", rev: "HEAD" } as const) : compare.head,
|
||||
mergeBase: true,
|
||||
};
|
||||
const result = client.readDiff(comparison);
|
||||
const request = wire.sent.find((value) => value.type === "subscribe_checkout_diff_request");
|
||||
expect(request?.compare).toMatchObject({ mode });
|
||||
wire.receive({
|
||||
type: "subscribe_checkout_diff_response",
|
||||
payload: {
|
||||
requestId: request?.requestId,
|
||||
subscriptionId: "legacy",
|
||||
cwd: "/repo",
|
||||
files: [],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
wire.receive({
|
||||
type: "checkout_diff_update",
|
||||
payload: { subscriptionId: "legacy", cwd: "/repo", files: [], error: null },
|
||||
});
|
||||
expect(await result).toMatchObject({ phase: "ready", files: [] });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
decodeDiffFrame,
|
||||
type DiffEntry as WireEntry,
|
||||
type DiffBegin,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type {
|
||||
DiffSubscribeRequest,
|
||||
SubscribeCheckoutDiffRequest,
|
||||
DiffError,
|
||||
DiffSubscribeResponse,
|
||||
SubscribeCheckoutDiffResponse,
|
||||
ParsedDiffFile,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { OwnedSubscription, SubscriptionObserver } from "../connection/index.js";
|
||||
import { DiffStream } from "./stream.js";
|
||||
export type { DiffEndpoint } from "@getpaseo/protocol/messages";
|
||||
export type DiffCompare = Omit<DiffSubscribeRequest, "type" | "requestId">;
|
||||
|
||||
export type DiffEntry = Omit<WireEntry, "body">;
|
||||
export type ResolvedShas = Pick<DiffBegin, "baseSha" | "headSha" | "mergeBaseSha">;
|
||||
|
||||
export type DiffState =
|
||||
| { phase: "loading"; revision: number; entries: DiffEntry[] }
|
||||
| {
|
||||
phase: "ready";
|
||||
revision: number;
|
||||
resolved: ResolvedShas;
|
||||
diffTooLarge: boolean;
|
||||
files: ParsedDiffFile[];
|
||||
}
|
||||
| { phase: "error"; revision: number; error: DiffError };
|
||||
type LegacyCompare = SubscribeCheckoutDiffRequest["compare"];
|
||||
|
||||
interface Port {
|
||||
modern(compare: DiffCompare): OwnedSubscription<DiffSubscribeResponse["payload"]>;
|
||||
legacy(
|
||||
cwd: string,
|
||||
compare: LegacyCompare,
|
||||
): OwnedSubscription<SubscribeCheckoutDiffResponse["payload"]>;
|
||||
failed(error: unknown): void;
|
||||
}
|
||||
export class DiffSubscriptionError extends Error {
|
||||
constructor(readonly detail: DiffError) {
|
||||
super(detail.message);
|
||||
}
|
||||
}
|
||||
interface Registration {
|
||||
start(modern: boolean): void;
|
||||
disconnected(): void;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
/** Capability selection, slot ownership and reconnect belong to this domain. */
|
||||
export class DiffStreamRouter {
|
||||
private registrations = new Set<Registration>();
|
||||
private slots = new Map<number, { receive(bytes: Uint8Array): void }>();
|
||||
private mode: boolean | null = null;
|
||||
constructor(private port: Port) {}
|
||||
connected(modern: boolean): void {
|
||||
this.mode = modern;
|
||||
for (const registration of this.registrations) registration.start(modern);
|
||||
}
|
||||
disconnected(): void {
|
||||
this.mode = null;
|
||||
this.slots.clear();
|
||||
for (const registration of this.registrations) registration.disconnected();
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
await Promise.all([...this.registrations].map((registration) => registration.release()));
|
||||
}
|
||||
receive(bytes: Uint8Array): void {
|
||||
const owner = this.slots.get(bytes[1]);
|
||||
owner?.receive(bytes);
|
||||
}
|
||||
private releaseBackend(backend: OwnedSubscription<unknown> | null): void {
|
||||
void backend?.release().catch(this.port.failed);
|
||||
}
|
||||
observe(compare: DiffCompare): OwnedSubscription<DiffState> {
|
||||
let backend: OwnedSubscription<unknown> | null = null;
|
||||
let state: (DiffState & { subscriptionId: string }) | null = null;
|
||||
let slot: number | null = null;
|
||||
let released = false;
|
||||
let generation = 0;
|
||||
let stream: DiffStream;
|
||||
const observers = new Set<SubscriptionObserver<DiffState & { subscriptionId: string }>>();
|
||||
let resolve!: (state: DiffState & { subscriptionId: string }) => void;
|
||||
const ready = new Promise<DiffState & { subscriptionId: string }>((accept) => {
|
||||
resolve = accept;
|
||||
});
|
||||
const publish = (value: DiffState) => {
|
||||
if (released) return;
|
||||
state = { ...value, subscriptionId: backend?.subscriptionId ?? "" };
|
||||
resolve(state);
|
||||
for (const observer of observers) {
|
||||
try {
|
||||
observer.snapshot(state);
|
||||
} catch (error) {
|
||||
this.port.failed(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
const detach = () => {
|
||||
if (slot !== null) this.slots.delete(slot);
|
||||
slot = null;
|
||||
};
|
||||
const fail = (error: unknown) => {
|
||||
detach();
|
||||
publish({
|
||||
phase: "error",
|
||||
revision: 0,
|
||||
error:
|
||||
error instanceof DiffSubscriptionError
|
||||
? error.detail
|
||||
: { code: "UNKNOWN", message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
this.releaseBackend(backend);
|
||||
};
|
||||
const registration: Registration = {
|
||||
start: (modern) => {
|
||||
if (released || backend) return;
|
||||
const current = ++generation;
|
||||
const failed = (error: unknown) => {
|
||||
if (current === generation) fail(error);
|
||||
};
|
||||
stream = new DiffStream(publish);
|
||||
if (modern) {
|
||||
const observation = this.port.modern(compare);
|
||||
backend = observation;
|
||||
observation.subscribe({
|
||||
snapshot: (payload) => {
|
||||
if (current !== generation) return;
|
||||
detach();
|
||||
if (payload.error) {
|
||||
fail(new DiffSubscriptionError(payload.error));
|
||||
return;
|
||||
}
|
||||
slot = payload.slot;
|
||||
const owner = {
|
||||
receive: (bytes: Uint8Array) => {
|
||||
try {
|
||||
stream.receive(decodeDiffFrame(bytes));
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
this.slots.set(slot, owner);
|
||||
},
|
||||
update: () => {},
|
||||
error: failed,
|
||||
});
|
||||
void observation.ready.catch(failed);
|
||||
} else {
|
||||
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
|
||||
const legacy = legacyCompare(compare);
|
||||
if (!legacy) {
|
||||
fail(
|
||||
new DiffSubscriptionError({
|
||||
code: "INVALID_COMPARE",
|
||||
message: "Update the host to use this comparison",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const observation = this.port.legacy(compare.cwd, legacy);
|
||||
backend = observation;
|
||||
const apply = (
|
||||
payload: Pick<
|
||||
SubscribeCheckoutDiffResponse["payload"],
|
||||
"files" | "error" | "diffTooLarge"
|
||||
>,
|
||||
) => {
|
||||
if (current !== generation) return;
|
||||
if (payload.error)
|
||||
publish({
|
||||
phase: "error",
|
||||
revision: 0,
|
||||
error: {
|
||||
code: payload.error.code === "NOT_GIT_REPO" ? "NOT_GIT_REPO" : "UNKNOWN",
|
||||
message: payload.error.message,
|
||||
},
|
||||
});
|
||||
else
|
||||
publish({
|
||||
phase: "ready",
|
||||
revision: (state?.revision ?? 0) + 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
diffTooLarge: payload.diffTooLarge === true,
|
||||
files: payload.files,
|
||||
});
|
||||
};
|
||||
observation.subscribe({
|
||||
snapshot: apply,
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") apply(message.payload);
|
||||
},
|
||||
error: failed,
|
||||
});
|
||||
void observation.ready.catch(failed);
|
||||
}
|
||||
},
|
||||
disconnected: () => {
|
||||
generation++;
|
||||
detach();
|
||||
state = null;
|
||||
const old = backend;
|
||||
backend = null;
|
||||
void old?.release().catch(this.port.failed);
|
||||
},
|
||||
release: async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
generation++;
|
||||
detach();
|
||||
this.registrations.delete(registration);
|
||||
await backend?.release();
|
||||
observers.clear();
|
||||
},
|
||||
};
|
||||
this.registrations.add(registration);
|
||||
if (this.mode !== null) registration.start(this.mode);
|
||||
return {
|
||||
get subscriptionId() {
|
||||
return backend?.subscriptionId ?? null;
|
||||
},
|
||||
ready,
|
||||
subscribe: (observer) => {
|
||||
observers.add(observer);
|
||||
if (state) observer.snapshot(state);
|
||||
return () => observers.delete(observer);
|
||||
},
|
||||
release: () => registration.release(),
|
||||
};
|
||||
}
|
||||
}
|
||||
function legacyCompare(compare: DiffCompare): LegacyCompare | null {
|
||||
if (compare.highlight === false) return null;
|
||||
const { base, head } = compare;
|
||||
if (base.kind !== "rev") return null;
|
||||
if (base.rev === "HEAD" && head.kind === "worktree")
|
||||
return { mode: "uncommitted", ignoreWhitespace: compare.ignoreWhitespace };
|
||||
if (compare.mergeBase && head.kind === "rev" && head.rev === "HEAD")
|
||||
return { mode: "base", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
|
||||
if (compare.mergeBase && head.kind === "worktree")
|
||||
return { mode: "all", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { DiffStream } from "./stream.js";
|
||||
import { DiffOpcode, type DiffBegin } from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { DiffState } from "./index.js";
|
||||
const entry = {
|
||||
path: "a.ts",
|
||||
oldPath: null,
|
||||
change: "modified" as const,
|
||||
content: "text" as const,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
body: true,
|
||||
};
|
||||
const begin = (revision: number, baseRevision = 0): DiffBegin => ({
|
||||
opcode: DiffOpcode.Begin,
|
||||
slot: 0,
|
||||
revision,
|
||||
baseRevision,
|
||||
baseSha: "a",
|
||||
headSha: "b",
|
||||
mergeBaseSha: "",
|
||||
entries: [entry],
|
||||
});
|
||||
const end = (revision: number) =>
|
||||
({
|
||||
opcode: DiffOpcode.End,
|
||||
slot: 0,
|
||||
revision,
|
||||
diffTooLarge: false,
|
||||
omitted: [],
|
||||
error: null,
|
||||
}) as const;
|
||||
test("publishes the initial tree, commits on End and buffers refreshes with stable equal files", () => {
|
||||
const states: DiffState[] = [];
|
||||
const stream = new DiffStream((state) => states.push(state));
|
||||
stream.receive(begin(1));
|
||||
expect(states[0]).toMatchObject({ phase: "loading", entries: [{ path: "a.ts" }] });
|
||||
stream.receive({
|
||||
opcode: DiffOpcode.Bodies,
|
||||
slot: 0,
|
||||
revision: 1,
|
||||
files: [{ path: "a.ts", hunks: [] }],
|
||||
});
|
||||
expect(states).toHaveLength(1);
|
||||
stream.receive(end(1));
|
||||
const ready = states[1];
|
||||
if (ready.phase !== "ready") throw new Error("Expected ready");
|
||||
stream.receive(begin(2, 1));
|
||||
stream.receive({
|
||||
opcode: DiffOpcode.Bodies,
|
||||
slot: 0,
|
||||
revision: 2,
|
||||
files: [{ path: "a.ts", hunks: [] }],
|
||||
});
|
||||
expect(states).toHaveLength(2);
|
||||
stream.receive(end(2));
|
||||
const refreshed = states[2];
|
||||
if (refreshed.phase !== "ready") throw new Error("Expected ready");
|
||||
expect(refreshed.files[0]).toBe(ready.files[0]);
|
||||
});
|
||||
test("superseded and stale frames cannot leave the client loading; End is authoritative", () => {
|
||||
const states: DiffState[] = [];
|
||||
const stream = new DiffStream((s) => states.push(s));
|
||||
stream.receive(begin(1));
|
||||
stream.receive(begin(2));
|
||||
stream.receive(end(1));
|
||||
expect(() => stream.receive(end(2))).toThrow(/Missing/);
|
||||
stream.receive(begin(3));
|
||||
stream.receive({ ...end(3), error: { code: "REF_NOT_FOUND", message: "gone" } });
|
||||
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "REF_NOT_FOUND" } });
|
||||
stream.receive({ ...begin(4), entries: [] });
|
||||
stream.receive(end(4));
|
||||
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
|
||||
});
|
||||
test("unchanged entries retain hunks, omissions override cache, removed paths disappear", () => {
|
||||
const states: DiffState[] = [];
|
||||
const stream = new DiffStream((s) => states.push(s));
|
||||
stream.receive(begin(1));
|
||||
stream.receive({ ...end(1), omitted: ["a.ts"], diffTooLarge: true });
|
||||
stream.receive({ ...begin(2, 1), entries: [{ ...entry, body: false }] });
|
||||
stream.receive(end(2));
|
||||
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [{ status: "too_large" }] });
|
||||
stream.receive({ ...begin(3, 2), entries: [] });
|
||||
stream.receive(end(3));
|
||||
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
DiffOpcode,
|
||||
materializeDiffFile,
|
||||
type DiffBegin,
|
||||
type DiffBody,
|
||||
type DiffFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import type { DiffState } from "./index.js";
|
||||
|
||||
/** Owns revision assembly. End, rather than a local body count, commits the snapshot. */
|
||||
export class DiffStream {
|
||||
private pending: { begin: DiffBegin; bodies: Map<string, DiffBody> } | null = null;
|
||||
private committed: Extract<DiffState, { phase: "ready" }> | null = null;
|
||||
constructor(private publish: (state: DiffState) => void) {}
|
||||
receive(frame: DiffFrame): void {
|
||||
if (frame.opcode === DiffOpcode.Begin) {
|
||||
if (frame.baseRevision !== 0 && frame.baseRevision !== this.committed?.revision)
|
||||
throw new Error("Unknown diff base revision");
|
||||
if (new Set(frame.entries.map((entry) => entry.path)).size !== frame.entries.length)
|
||||
throw new Error("Duplicate diff entry");
|
||||
this.pending = { begin: frame, bodies: new Map() };
|
||||
if (!this.committed)
|
||||
this.publish({
|
||||
phase: "loading",
|
||||
revision: frame.revision,
|
||||
entries: frame.entries.map(({ body: _body, ...entry }) => entry),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pending = this.pending;
|
||||
if (!pending || frame.revision !== pending.begin.revision) return;
|
||||
if (frame.opcode === DiffOpcode.Bodies) {
|
||||
for (const body of frame.files) {
|
||||
if (pending.bodies.has(body.path)) throw new Error("Duplicate diff body");
|
||||
const entry = pending.begin.entries.find((candidate) => candidate.path === body.path);
|
||||
if (!entry?.body) throw new Error("Unexpected diff body");
|
||||
pending.bodies.set(body.path, body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.pending = null;
|
||||
if (frame.error) {
|
||||
this.committed = null;
|
||||
this.publish({ phase: "error", revision: frame.revision, error: frame.error });
|
||||
return;
|
||||
}
|
||||
const previous = new Map(this.committed?.files.map((file) => [file.path, file]));
|
||||
const omitted = new Set(frame.omitted);
|
||||
for (const path of omitted)
|
||||
if (!pending.begin.entries.some((entry) => entry.path === path))
|
||||
throw new Error("Unknown omitted diff path");
|
||||
const files = pending.begin.entries.map((entry) => {
|
||||
const old = previous.get(entry.path);
|
||||
const body = pending.bodies.get(entry.path);
|
||||
if (entry.content === "text" && !omitted.has(entry.path) && entry.body && !body)
|
||||
throw new Error(`Missing diff body: ${entry.path}`);
|
||||
if (
|
||||
entry.content === "text" &&
|
||||
!entry.body &&
|
||||
!omitted.has(entry.path) &&
|
||||
(!old || pending.begin.baseRevision === 0)
|
||||
)
|
||||
throw new Error(`Missing inherited diff: ${entry.path}`);
|
||||
let file = materializeDiffFile(entry, body ?? null, omitted.has(entry.path));
|
||||
if (entry.content === "text" && !entry.body && !omitted.has(entry.path) && old)
|
||||
file = { ...file, hunks: old.hunks, status: old.status };
|
||||
return old && sameFile(old, file) ? old : file;
|
||||
});
|
||||
const { revision, baseSha, headSha, mergeBaseSha } = pending.begin;
|
||||
this.committed = {
|
||||
phase: "ready",
|
||||
revision,
|
||||
resolved: { baseSha, headSha, mergeBaseSha },
|
||||
files,
|
||||
diffTooLarge: frame.diffTooLarge,
|
||||
};
|
||||
this.publish(this.committed);
|
||||
}
|
||||
}
|
||||
function sameFile(a: ParsedDiffFile, b: ParsedDiffFile): boolean {
|
||||
if (
|
||||
a.path !== b.path ||
|
||||
a.oldPath !== b.oldPath ||
|
||||
a.isNew !== b.isNew ||
|
||||
a.isDeleted !== b.isDeleted ||
|
||||
a.additions !== b.additions ||
|
||||
a.deletions !== b.deletions ||
|
||||
a.status !== b.status
|
||||
)
|
||||
return false;
|
||||
if (a.hunks === b.hunks) return true;
|
||||
return JSON.stringify(a.hunks) === JSON.stringify(b.hunks);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user