Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] f792ac8a4e chore: update panel scrolling branch from main 2026-11-06 07:49:20 +00:00
paseo-bot[bot] e8a9e2d699 chore(android): carry panel scrolling fix onto the current timeline 2026-11-06 07:48:53 +00:00
paseo-bot[bot]andluckxine 5feec6fa35 fix(android): contain nested panel motion in the current timeline
Identify the forward-order timeline by its existing native identity so panel edges stay still while ordinary parents retain Android handoff. Preserve the native content-position corrections.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-06 07:46:51 +00:00
paseo-bot[bot]andluckxine 138d9d63ab fix(android): keep panel overscroll inside inverted timelines
Android forwards a nested panel’s unconsumed scroll and fling to its inverted parent, moving the timeline in the opposite visual direction. Keep that motion inside the panel while preserving ordinary parent scrolling and nested-scroll registration.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-01 14:32:01 +00:00
88 changed files with 795 additions and 4889 deletions
-21
View File
@@ -259,27 +259,6 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
The daemon runs one archive lane per workspace, shared by every connection
(`packages/server/src/server/workspace-archive-operations.ts`):
| A request arrives while | Result |
| ------------------------ | --------------------------------------------------------- |
| an archive runs: archive | joins the running archive |
| an archive runs: undo | aborts the archive, waits for it to settle, then restores |
| an undo runs: archive | waits for the undo, then archives |
| an undo runs: undo | joins the running undo |
A request takes its place in the lane before its first `await`. Arrival order decides, so an undo
sent right behind its archive still cancels it.
An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only
the agents that archive itself archived. Deleting the worktree directory is the point of no
return: an undo after it recreates the worktree through workspace recovery. The client shows or
hides the row by the latest local intent, so a late remove or upsert from a superseded request
does not flip it back. Tabs and layout are purged only once the undo window closes with the
archive committed.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
+11 -2
View File
@@ -179,8 +179,17 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
lists the corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
lists the content-position corrections and the React Native issues they work around.
The timeline's `nativeID` (`agent-chat-scroll-native-virtualized`) also identifies it to the native
scroll owner: a nested panel's edge overshoot and unconsumed fling must leave the timeline still.
Ordinary scroll parents retain Android's handoff. Keep that identity in sync with
`agent-stream/strategy-native.tsx`; the end pin is absent while reading history, so it cannot
identify the timeline for this rule.
`VisibleContentPositionClampTest` mounts through React Native's own mounting manager;
`PanelEdgeTest` covers both panel edges, flings, interior/direct scrolling, and ordinary-parent
handoff with the real compiled scroll views:
```bash
cd packages/app/android
+2 -6
View File
@@ -68,11 +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. `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()`.
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`).
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()`.
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.
@@ -144,7 +140,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
-7
View File
@@ -84,10 +84,3 @@ its DOM-derived scope.
Escape interrupts an agent only in the message input or an unclaimed surface.
A focused terminal excludes that binding. The source leaves terminal Escape alone;
terminal Escape/Ctrl forwarding is a separate feature.
## Keys shared with text editing
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
workspace archive undo, declares `editable: false` and registers its handler with
`isActive` true only while it has work. An inactive handler leaves the key
unhandled, so the pipeline skips `preventDefault` and the platform's own undo runs.
@@ -1,14 +1,8 @@
import { test, expect } from "../support/fixtures";
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { fillComposerDraft } from "../support/helpers/composer";
import {
expectComposerToolbarOnTextRails,
expectNoCollapsedComposerToolbarFrame,
expectNoFrameTruncatesModelBesideThinking,
readSettledModelTriggerLabels,
recordComposerToolbarFrames,
recordModelTriggerFramesFromLoad,
sweepModelTriggerLabels,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -130,128 +124,3 @@ test.describe("Composer controls folding on a touch device", () => {
}
});
});
test.describe("Folded model control on a phone", () => {
// Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit.
test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
test("hides the thinking label before the model label truncates", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-density-labels-",
title: "Label priority chat",
model: "ten-second-stream",
});
try {
await recordModelTriggerFramesFromLoad(page);
await openAgentRoute(page, session);
await expect(
page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }),
).toHaveText("Low");
await readSettledModelTriggerLabels(page);
// From the first frame on, the control never guesses: it paints both labels only once it
// has measured that they fit.
await expectNoFrameTruncatesModelBesideThinking(page);
const sweep = await sweepModelTriggerLabels(page, {
from: 480,
to: 280,
step: 4,
height: 844,
});
expect(sweep.widest).toMatchObject({
modelText: "Ten second stream",
thinkingShown: true,
modelTruncated: false,
});
expect(sweep.thinkingHidden).toMatchObject({
modelText: "Ten second stream",
modelTruncated: false,
});
expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width);
// A clean tail: the label ends on a whole word and the ellipsis, never on a space.
expect(sweep.modelTruncated).toMatchObject({
modelText: "Ten second…",
thinkingShown: false,
});
} finally {
await session.cleanup();
}
});
});
test.describe("Composer toolbar rails on touch", () => {
test.use({ isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
for (const viewport of [
{ device: "phone", width: 390, height: 844 },
{ device: "tablet", width: 1024, height: 1366 },
]) {
test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({
page,
}) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const idle = await seedMockAgentWorkspace({
repoPrefix: "composer-rails-idle-",
title: "Rails idle chat",
});
let running: Awaited<ReturnType<typeof seedMockAgentWorkspace>> | null = null;
try {
await openAgentRoute(page, idle);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
await test.step("empty composer: attach on the left, voice mode on the right", async () => {
await expectComposerToolbarOnTextRails(page);
});
await test.step("draft: the send disc on the right", async () => {
await fillComposerDraft(page, "Refactor the composer toolbar");
await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible();
await expectComposerToolbarOnTextRails(page);
});
running = await seedMockAgentWorkspace({
repoPrefix: "composer-rails-running-",
title: "Rails running chat",
model: "one-minute-stream",
initialPrompt: "stream for a while",
});
await openAgentRoute(page, running);
await test.step("running agent: the stop disc on the right", async () => {
await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({
timeout: 30_000,
});
await expectComposerToolbarOnTextRails(page);
});
} finally {
await idle.cleanup();
await running?.cleanup();
}
});
}
});
test.describe("Composer trailing disc on a new chat", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } });
test("an empty draft holds a disabled send disc until there is something to send", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const send = page
.getByTestId("message-input-root")
.filter({ visible: true })
.getByRole("button", { name: "Send message", exact: true });
await expect(send).toBeDisabled({ timeout: 30_000 });
await fillComposerDraft(page, "Refactor the composer toolbar");
await expect(send).toBeEnabled();
} finally {
await workspace.cleanup();
}
});
});
@@ -1,186 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
import {
archiveFromRowButton,
createGatedTeardown,
expectDirectoryPresence,
expectHostWorkspaceSettledActive,
outlastUndoWindow,
pressUndoShortcut,
rowArchiveButton,
rowKebab,
undoButton,
workspaceRow,
workspaceTitleWidth,
} from "../support/helpers/workspace-archive-undo";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
test.describe("Workspace archive undo", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const worktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
tempRepo = await createTempGitRepo("archive-undo-");
});
test.afterEach(async () => {
for (const directory of worktreeDirectories) {
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
}
worktreeDirectories.clear();
await client?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
});
async function seedWorktree(page: Page, slug: string) {
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `${slug}-${Date.now()}`,
});
worktreeDirectories.add(worktree.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: worktree.workspaceId,
});
return worktree;
}
test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-button-overlay");
await page.mouse.move(0, 0);
await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0);
const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).hover();
await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible();
await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible();
expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth);
const [buttonBox, kebabBox] = await Promise.all([
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
rowKebab(page, worktree.workspaceId).boundingBox(),
]);
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
});
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-shortcut");
await archiveFromRowButton(page, worktree.workspaceId);
await pressUndoShortcut(page);
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
await expect(undoButton(page)).toHaveCount(0);
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
// Once the host settled, any remove the canceled archive sent has already arrived.
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
await expectDirectoryPresence(worktree.workspaceDirectory, true);
});
test("Undo after the worktree was removed recreates it", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-recreate");
await archiveFromRowButton(page, worktree.workspaceId);
// Hovering the toast holds it, and with it the undo, open while the archive finishes.
await undoButton(page).hover();
await expectDirectoryPresence(worktree.workspaceDirectory, false);
await undoButton(page).click();
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
await expectDirectoryPresence(worktree.workspaceDirectory, true);
});
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
page,
}) => {
await page.clock.install({ time: Date.now() });
const worktree = await seedWorktree(page, "archive-undo-hold");
await archiveFromRowButton(page, worktree.workspaceId);
await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } });
await undoButton(page).hover();
await outlastUndoWindow(page);
await expect(undoButton(page)).toBeVisible();
await page.mouse.move(0, 0);
await outlastUndoWindow(page);
await expect(undoButton(page)).toHaveCount(0);
// The toast took the undo with it.
await pressUndoShortcut(page);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
});
test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => {
const teardown = createGatedTeardown();
const repo = await createTempGitRepo("archive-undo-tabs-", {
paseoConfig: teardown.paseoConfig,
});
const seedClient = await connectSeedClient();
try {
await openProjectViaDaemon(client, repo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: repo.path,
slug: `archive-undo-tabs-${Date.now()}`,
});
worktreeDirectories.add(worktree.workspaceDirectory);
const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId };
const agents = [
await createMockIdleAgent(seedClient, { ...target, title: "first" }),
await createMockIdleAgent(seedClient, { ...target, title: "second" }),
] as const;
await openWorkspaceWithAgents(page, [agents[0], agents[1]]);
const tabsBefore = await getWorkspaceTabTestIds(page);
await archiveFromRowButton(page, worktree.workspaceId);
// The archive has taken its agents down and now parks in teardown.
await teardown.expectStarted();
await undoButton(page).click();
await workspaceRow(page, worktree.workspaceId).click();
for (const agent of agents) {
await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible();
}
teardown.release();
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore);
} finally {
teardown.release();
await seedClient.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-none");
await pressUndoShortcut(page);
await expect(undoButton(page)).toHaveCount(0);
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
});
});
@@ -53,192 +53,3 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
}
/** What the folded model control's labels show, read from the DOM. */
export interface ModelTriggerLabelState {
thinkingShown: boolean;
modelShown: boolean;
modelText: string;
modelTruncated: boolean;
}
declare global {
interface Window {
__modelTriggerFrames?: ModelTriggerLabelState[];
}
}
function readModelTriggerLabelStateInPage(): ModelTriggerLabelState {
const visible = (node: Element) => node.getClientRects().length > 0;
const model = Array.from(
document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'),
).find(visible);
const thinking = Array.from(
document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'),
).find(visible);
const shown = (node: Element | null): boolean => {
for (let current = node; current; current = current.parentElement) {
if (Number(getComputedStyle(current).opacity) === 0) return false;
}
return true;
};
const modelShown = model !== undefined && shown(model);
const modelText = model?.textContent ?? "";
return {
modelShown,
modelText,
thinkingShown: modelShown && thinking !== undefined,
modelTruncated:
model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth),
};
}
/** Records the folded model control's labels on every painted frame from page load. */
export async function recordModelTriggerFramesFromLoad(page: Page): Promise<void> {
await page.addInitScript(`(() => {
const read = ${readModelTriggerLabelStateInPage.toString()};
const frames = [];
window.__modelTriggerFrames = frames;
const tick = () => {
frames.push(read());
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
}
/** The model control's labels once two reads 100ms apart agree. */
export async function readSettledModelTriggerLabels(page: Page): Promise<ModelTriggerLabelState> {
let previous = await page.evaluate(readModelTriggerLabelStateInPage);
for (;;) {
await page.waitForTimeout(100);
const next = await page.evaluate(readModelTriggerLabelStateInPage);
if (JSON.stringify(next) === JSON.stringify(previous)) return next;
previous = next;
}
}
/** The labels at the widths where the folded model control changes what it shows. */
export interface ModelTriggerLabelSweep {
/** The labels at the widest width. */
widest: ModelTriggerLabelState & { width: number };
/** The first width at which the thinking label is hidden. */
thinkingHidden: ModelTriggerLabelState & { width: number };
/** The first width at which the model label is truncated. */
modelTruncated: ModelTriggerLabelState & { width: number };
}
/**
* Narrows the viewport step by step and records where the thinking label disappears and where the
* model label first truncates.
*/
export async function sweepModelTriggerLabels(
page: Page,
widths: { from: number; to: number; step: number; height: number },
): Promise<ModelTriggerLabelSweep> {
const steps: (ModelTriggerLabelState & { width: number })[] = [];
for (let width = widths.from; width >= widths.to; width -= widths.step) {
await page.setViewportSize({ width, height: widths.height });
const labels = await readSettledModelTriggerLabels(page);
steps.push({ ...labels, width });
if (labels.modelTruncated || !labels.modelShown) break;
}
const thinkingHidden = steps.find((step) => !step.thinkingShown);
const modelTruncated = steps.find((step) => step.modelTruncated);
if (!thinkingHidden || !modelTruncated) {
throw new Error(`the sweep never hid the thinking label and truncated the model label`);
}
return { widest: steps[0], thinkingHidden, modelTruncated };
}
/** No frame recorded so far painted a truncated model label beside the thinking label. */
export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise<void> {
const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []);
expect(frames.some((frame) => frame.modelShown)).toBe(true);
const wrong = frames
.map((frame, index) => ({ ...frame, index }))
.filter((frame) => frame.thinkingShown && frame.modelTruncated);
expect(wrong, `of ${frames.length} frames`).toEqual([]);
}
/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */
export interface ComposerToolbarRails {
/** Where the text column starts. */
textLeft: number;
/** Where the toolbar's leftmost ink starts. */
inkLeft: number;
/** How far the trailing disc sits from the composer's right edge. */
discRightInset: number;
/** How far the trailing disc sits from the composer's bottom edge. */
discBottomInset: number;
}
/**
* Reads where the toolbar's ink starts against the text column and where the trailing disc sits
* in the composer's corner. A control's ink is its filled disc when it has one, otherwise its
* glyph's stroked geometry.
*/
export async function readComposerToolbarRails(page: Page): Promise<ComposerToolbarRails> {
const root = page.locator('[data-testid="message-input-root"]:visible').first();
return root.evaluate((element) => {
const textarea = element.querySelector("textarea");
if (!textarea) throw new Error("composer has no text input");
const composerRect = element.getBoundingClientRect();
const textLeft =
textarea.getBoundingClientRect().left +
Number.parseFloat(getComputedStyle(textarea).paddingLeft);
const isFilled = (node: Element) => {
const color = getComputedStyle(node).backgroundColor;
return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
};
const glyphInkLeft = (svg: SVGSVGElement) => {
let left = Number.POSITIVE_INFINITY;
for (const shape of Array.from(
svg.querySelectorAll<SVGGraphicsElement>("path, circle, rect, line, polyline"),
)) {
const matrix = shape.getScreenCTM();
if (!matrix) continue;
const box = shape.getBBox();
const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0");
left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e);
}
return left;
};
const band = composerRect.bottom - 64;
let inkLeft = Number.POSITIVE_INFINITY;
let trailingDisc: DOMRect | null = null;
for (const control of Array.from(element.querySelectorAll('[role="button"]'))) {
const rect = control.getBoundingClientRect();
if (rect.width === 0 || rect.top < band) continue;
const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled);
const svg = control.querySelector("svg");
if (disc) {
const discRect = disc.getBoundingClientRect();
inkLeft = Math.min(inkLeft, discRect.left);
if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect;
} else if (svg) {
inkLeft = Math.min(inkLeft, glyphInkLeft(svg));
}
}
if (!trailingDisc) throw new Error("composer toolbar has no trailing disc");
return {
textLeft,
inkLeft,
discRightInset: composerRect.right - trailingDisc.right,
discBottomInset: composerRect.bottom - trailingDisc.bottom,
};
});
}
/**
* The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the
* corner, as far from the right edge as from the bottom edge. Within half a pixel.
*/
export async function expectComposerToolbarOnTextRails(page: Page): Promise<void> {
const rails = await readComposerToolbarRails(page);
// A precision of 0 digits passes within half a pixel.
expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0);
expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0);
}
@@ -1,97 +0,0 @@
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect } from "../fixtures";
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
const UNDO_WINDOW_MS = 6000;
export function workspaceRow(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
export function rowArchiveButton(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-archive-button-${getServerId()}:${workspaceId}`);
}
export function rowKebab(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`);
}
export function undoButton(page: Page) {
return page.getByRole("button", { name: /^Undo/ });
}
export async function workspaceTitleWidth(page: Page, workspaceId: string): Promise<number> {
const box = await page
.getByTestId(`sidebar-workspace-title-${getServerId()}:${workspaceId}`)
.boundingBox();
if (!box) throw new Error(`Title of ${workspaceId} is not laid out`);
return box.width;
}
/** Archives a workspace with its row's hover button and waits for the undo toast. */
export async function archiveFromRowButton(page: Page, workspaceId: string): Promise<void> {
page.once("dialog", (dialog) => void dialog.accept());
await workspaceRow(page, workspaceId).hover();
await rowArchiveButton(page, workspaceId).click();
await expectWorkspaceAbsentFromSidebar(page, workspaceId);
await expect(undoButton(page)).toBeVisible();
}
export async function pressUndoShortcut(page: Page): Promise<void> {
await page.keyboard.press(UNDO_SHORTCUT);
}
/** Moves the page clock past the moment an unheld archived toast would have closed. */
export async function outlastUndoWindow(page: Page): Promise<void> {
await page.clock.fastForward(UNDO_WINDOW_MS + 1000);
}
/** Waits until the host lists the workspace as active with no archive running on it. */
export async function expectHostWorkspaceSettledActive(
client: DaemonClient,
workspaceId: string,
): Promise<void> {
await expect
.poll(
async () => {
const { entries } = await client.fetchWorkspaces();
const entry = entries.find((candidate) => candidate.id === workspaceId);
return entry ? { active: true, archiving: Boolean(entry.archivingAt) } : null;
},
{ timeout: 30_000 },
)
.toEqual({ active: true, archiving: false });
}
export async function expectDirectoryPresence(directory: string, present: boolean): Promise<void> {
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
}
export interface GatedTeardown {
/** The `paseo.json` config whose worktree teardown parks until released. */
paseoConfig: Record<string, unknown>;
expectStarted(): Promise<void>;
release(): void;
}
/** A worktree teardown that parks an archive mid-flight, so an undo lands while it runs. */
export function createGatedTeardown(): GatedTeardown {
const gateDir = mkdtempSync(path.join(tmpdir(), "archive-undo-gate-"));
const startedPath = path.join(gateDir, "started");
const releasePath = path.join(gateDir, "release");
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${startedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
return {
paseoConfig: { worktree: { teardown: [teardown] } },
expectStarted: () => expectDirectoryPresence(startedPath, true),
release: () => writeFileSync(releasePath, "release\n"),
};
}
@@ -0,0 +1,158 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import android.view.View
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.soloader.SoLoader
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Forward-order parent, as used by the current Android timeline, and a nested panel at its edge. */
@RunWith(AndroidJUnit4::class)
class PanelEdgeTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
private lateinit var manager: ReactScrollViewManager
@After fun finish() {
instrumentation.runOnMainSync { activity?.finish() }
}
@Test fun panelBottomEdgeLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
child.stopNestedScroll()
assertEquals("Panel stays at its bottom", 600, child.scrollY)
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
}
}
@Test fun panelTopEdgeLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 0)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, -100, null)
child.stopNestedScroll()
assertEquals("Panel stays at its top", 0, child.scrollY)
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
}
}
@Test fun panelFlingLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedFling(0f, 1500f, false)
child.stopNestedScroll()
}
SystemClock.sleep(500)
instrumentation.runOnMainSync {
assertEquals("Panel fling must leave timeline still", 400, parent.scrollY)
}
}
@Test fun disablingChildNestedScrollPreventsHandoff() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.isNestedScrollingEnabled = false
assertEquals(false, child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
assertEquals(400, parent.scrollY)
}
}
@Test fun ordinaryParentStillConsumesNestedOvershoot() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
manager.setNativeId(parent, "ordinary-scroll-parent")
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
child.stopNestedScroll()
assertEquals("Ordinary parent retains framework handoff", 500, parent.scrollY)
}
}
@Test fun ordinaryParentStillConsumesUnconsumedFling() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
manager.setNativeId(parent, "ordinary-scroll-parent")
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedFling(0f, 1500f, false)
child.stopNestedScroll()
}
SystemClock.sleep(500)
instrumentation.runOnMainSync { assertTrue(parent.scrollY > 400) }
}
@Test fun consumedPanelFlingDoesNotStartParentFling() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
assertEquals(false, child.dispatchNestedFling(0f, 1500f, true))
child.stopNestedScroll()
assertEquals(400, parent.scrollY)
}
}
@Test fun panelInteriorScrollingLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 100)
assertEquals(100, child.scrollY)
assertEquals(400, parent.scrollY)
parent.scrollTo(0, 500)
assertEquals("Direct timeline scrolling remains available", 500, parent.scrollY)
}
}
private fun openPanel(): Pair<ReactScrollView, ReactScrollView> {
val started = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = started
lateinit var parent: ReactScrollView
lateinit var child: ReactScrollView
instrumentation.runOnMainSync {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
SoLoader.init(started, OpenSourceMergedSoMapping)
val react = ScrollTestContext(started.applicationContext)
val context = ThemedReactContext(react, started)
manager = PaseoScrollPackage().createViewManager(react, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
parent = manager.createViewInstance(context)
manager.setNativeId(parent, "agent-chat-scroll-native-virtualized")
child = manager.createViewInstance(context)
child.isNestedScrollingEnabled = true
val content = ReactViewGroup(context)
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 400 })
parent.addView(content)
val childContent = ReactViewGroup(context)
child.addView(childContent)
started.setContentView(FrameLayout(started).apply { addView(parent, FrameLayout.LayoutParams(300, 400)) })
parent.layout(0, 0, 300, 400)
content.layout(0, 0, 300, 2000)
child.layout(0, 400, 300, 600)
childContent.layout(0, 0, 300, 800)
parent.scrollTo(0, 400)
}
return Pair(parent, child)
}
}
@@ -0,0 +1,19 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's own event sink for a context without JavaScript. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -12,10 +12,13 @@ import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with two corrections. Java, because the content-position
* React Native's vertical scroll view with timeline corrections. Java, because the content-position
* config type is internal to React Native's Kotlin module.
*/
final class PaseoScrollView extends ReactScrollView {
// The chat timeline, packages/app/src/agent-stream/strategy-native.tsx.
private static final String TIMELINE_NATIVE_ID = "agent-chat-scroll-native-virtualized";
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
@@ -32,6 +35,26 @@ final class PaseoScrollView extends ReactScrollView {
super(context);
}
// A panel owns its gesture through its edges; remaining child motion must not move the chat
// behind it. The timeline's native identity stays present when its end pin is removed for
// reading history. Ordinary parents retain Android's nested-scroll handoff.
@Override
public void onNestedScroll(
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
if (isTimeline()) return;
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
}
@Override
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
if (isTimeline() && !consumed) return true;
return super.onNestedFling(target, velocityX, velocityY, consumed);
}
private boolean isTimeline() {
return TIMELINE_NATIVE_ID.equals(getTag(R.id.view_tag_native_id));
}
@Override
public void setMaintainVisibleContentPosition(
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
@@ -54,28 +54,6 @@ using namespace facebook::react;
[[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self];
}
// iOS 26 blurs a scroll view's edges and places the blur in the scroll view's own coordinates,
// ignoring transforms. The keyboard lifts the chat with a translateY, so on iPhones the blur lands
// over the readable chat and stays until the next scroll (#7883). React Native hides it for
// inverted lists for the same reason (https://github.com/react/react-native/pull/58684). The app
// draws its own header fade, so every edge is hidden; hiding only top and bottom leaves the side
// effects, which also cover a transformed list on device.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0
if (@available(iOS 26.0, *)) {
UIScrollView *scrollView = self.scrollView;
scrollView.topEdgeEffect.hidden = YES;
scrollView.bottomEdgeEffect.hidden = YES;
scrollView.leftEdgeEffect.hidden = YES;
scrollView.rightEdgeEffect.hidden = YES;
}
#endif
}
return self;
}
- (void)mountingTransactionWillMount:(const MountingTransaction &)transaction
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
{
-1
View File
@@ -18,7 +18,6 @@ import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol
export interface AgentControlIconProps {
size: number;
color: string;
strokeWidth?: number;
}
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
@@ -675,6 +675,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
extraData={renderHistoryMountedRow}
keyExtractor={keyExtractor}
testID="agent-chat-scroll"
// The Android scroll owner identifies the timeline to contain nested panel gestures.
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={historyStartContent}
ListFooterComponent={liveAuxiliaryContent ?? undefined}
-2
View File
@@ -55,7 +55,6 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { WorkspaceArchiveUndoShortcutHost } from "@/workspace/archive-undo";
import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
@@ -558,7 +557,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<WorkspaceArchiveUndoShortcutHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -13,11 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
TOOLBAR_GLYPH_SIZES,
toolbarContentSlot,
type ToolbarGlyph,
} from "@/components/ui/control-geometry";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -65,7 +61,7 @@ interface CombinedModelSelectorProps {
*/
triggerFill?: boolean;
toolbar?: {
glyph: ToolbarGlyph;
glyphSize: number;
showCaret: boolean;
};
}
@@ -257,13 +253,11 @@ export function CombinedModelSelector({
chevron={toolbar?.showCaret === false ? null : undefined}
>
{selectedProvider.trim().length > 0 ? (
<View style={toolbar ? styles.toolbarGlyph : styles.glyph}>
<View style={toolbar?.glyphSize === 20 ? styles.toolbarGlyph20 : styles.toolbarGlyph16}>
<ModelProviderGlyph
provider={selectedProvider}
serverId={serverId}
size={toolbar?.glyph.size ?? ICON_SIZE.md}
strokeWidth={toolbar?.glyph.strokeWidth}
tone={toolbar?.glyph.tone}
size={toolbar?.glyphSize ?? ICON_SIZE.md}
/>
</View>
) : null}
@@ -299,30 +293,31 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: 0,
},
trigger: {
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
glyph: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
toolbarGlyph16: {
width: 16,
height: 16,
flexShrink: 0,
},
toolbarGlyph: {
width: TOOLBAR_GLYPH_SIZES.label,
height: TOOLBAR_GLYPH_SIZES.label,
toolbarGlyph20: {
width: 20,
height: 20,
flexShrink: 0,
},
triggerPressed: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
},
triggerDisabled: {
opacity: 0.5,
@@ -334,11 +329,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
// A custom trigger, such as a form field, owns its own box: none of the toolbar slot applies.
customTriggerWrapper: {
paddingHorizontal: 0,
paddingVertical: 0,
marginHorizontal: 0,
height: "auto",
},
triggerFill: {
@@ -1,11 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import {
Pressable,
type PressableStateCallbackType,
Text,
View,
useWindowDimensions,
} from "react-native";
import { Pressable, Text, View, useWindowDimensions } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -15,7 +9,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { TOOLBAR_DISC_SIZE, type ToolbarRing } from "@/components/ui/control-geometry";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
@@ -27,8 +21,8 @@ interface ContextWindowMeterProps {
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
/** The ring's box and stroke when it sits in an icon toolbar. */
toolbarRing?: ToolbarRing;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
@@ -77,7 +71,7 @@ function getProgressColor(percentage: number, theme: Theme): string {
return theme.colors.foregroundMuted;
}
function getMeterGeometry(showPercentage: boolean, ring?: ToolbarRing) {
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
@@ -86,8 +80,8 @@ function getMeterGeometry(showPercentage: boolean, ring?: ToolbarRing) {
containerStyle: styles.containerWithLabel,
};
}
const resolvedSize = ring?.size ?? SVG_SIZE;
const resolvedStrokeWidth = ring?.strokeWidth ?? STROKE_WIDTH;
const resolvedSize = glyphSize ?? SVG_SIZE;
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
@@ -157,7 +151,7 @@ export function ContextWindowMeter({
usedTokens,
totalCostUsd,
showPercentage = false,
toolbarRing,
glyphSize,
}: ContextWindowMeterProps) {
const { t, i18n } = useTranslation();
const { width } = useWindowDimensions();
@@ -171,7 +165,7 @@ export function ContextWindowMeter({
const closeSheet = useCallback(() => setIsSheetOpen(false), []);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const geometry = getMeterGeometry(showPercentage, toolbarRing);
const geometry = getMeterGeometry(showPercentage, glyphSize);
const context = useMemo(
() =>
@@ -190,10 +184,6 @@ export function ContextWindowMeter({
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = geometry.containerStyle;
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [containerStyle, pressed && styles.pressed],
[containerStyle],
);
const ring = (
<ContextWindowRing
size={geometry.svgSize}
@@ -217,7 +207,7 @@ export function ContextWindowMeter({
return (
<>
<Pressable
style={pressableStyle}
style={containerStyle}
testID="context-window-meter"
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
@@ -251,7 +241,7 @@ export function ContextWindowMeter({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={pressableStyle}
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={accessibilityLabel}
@@ -317,23 +307,20 @@ export function ContextWindowMeter({
const styles = StyleSheet.create((theme) => ({
container: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
containerWithLabel: {
height: TOOLBAR_DISC_SIZE,
height: TOOLBAR_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
pressed: {
backgroundColor: theme.colors.surface2,
},
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
@@ -216,19 +216,17 @@ export function ModelProviderGlyph({
provider,
serverId,
size,
strokeWidth,
tone = "muted",
}: {
provider: string;
serverId: string | null;
size: number;
strokeWidth?: number;
tone?: ProviderGlyphTone;
}) {
const Icon = useProviderIcon(provider, serverId);
const color =
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
return <Icon size={size} strokeWidth={strokeWidth} color={color} />;
return <Icon size={size} color={color} />;
}
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
@@ -20,8 +20,6 @@ import { resolveProviderIconName } from "@/components/provider-icon-name";
export interface ProviderIconProps {
size: number;
color: string;
/** Honoured by outline glyphs such as the fallback; brand marks are filled and ignore it. */
strokeWidth?: number;
}
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
@@ -649,7 +649,6 @@ function WorkspaceRowRightGroup({
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
@@ -676,8 +675,6 @@ function WorkspaceRowRightGroup({
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
@@ -872,7 +872,6 @@ function StatusWorkspaceRowInnerContent({
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
@@ -952,7 +951,6 @@ function StatusWorkspaceRowInnerContent({
trailing={trailing}
trailingPresentation={trailingPresentation}
showKebab={showKebabInSlot}
showArchiveButton={showArchiveButton}
showScrim={showScrim}
reserveSlotWidth={reserveSlotWidth}
isPinned={isPinned}
@@ -984,7 +982,6 @@ function StatusWorkspaceActionSlot({
trailing,
trailingPresentation,
showKebab,
showArchiveButton,
showScrim,
reserveSlotWidth,
isPinned,
@@ -1005,7 +1002,6 @@ function StatusWorkspaceActionSlot({
trailing: SidebarWorkspaceTrailing;
trailingPresentation: SidebarWorkspaceTrailingPresentation;
showKebab: boolean;
showArchiveButton: boolean;
showScrim: boolean;
reserveSlotWidth: boolean;
isPinned?: boolean;
@@ -1030,8 +1026,6 @@ function StatusWorkspaceActionSlot({
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{kebab.showKebab && onArchive ? (
<SidebarWorkspaceMenu
@@ -1,6 +1,6 @@
import { useMemo, type ComponentProps, type PropsWithChildren, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { PressableStateCallbackType } from "react-native";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Archive,
@@ -13,7 +13,6 @@ import {
PinOff,
Tag,
} from "lucide-react-native";
import { PressHighlight } from "@/components/ui/press-highlight";
import { isWeb } from "@/constants/platform";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
@@ -73,9 +72,6 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
/** Pointer-only, so it is always the kebab's pointer size. */
export const ARCHIVE_BUTTON_SIZE = 18;
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
@@ -83,33 +79,6 @@ function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
return <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
}
function renderArchiveIcon({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
return <ThemedArchive uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
}
/** One-click archive beside the kebab, for pointer rows that reveal it on hover. */
export function SidebarWorkspaceArchiveButton({
workspaceKey,
onArchive,
}: {
workspaceKey: string;
onArchive: () => void;
}) {
const { t } = useTranslation();
return (
<PressHighlight
hitSlop={4}
style={archiveButtonStyle}
onPress={onArchive}
accessibilityRole={isWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.workspace.actions.archiveWorkspace")}
testID={`sidebar-workspace-archive-button-${workspaceKey}`}
>
{renderArchiveIcon}
</PressHighlight>
);
}
export interface SidebarWorkspaceMenuProps {
workspaceKey: string;
serverId?: string;
@@ -468,12 +437,6 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
return [styles.trigger, hovered && styles.triggerHovered];
}
function archiveButtonStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.archiveButton, hovered && styles.triggerHovered];
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// Larger on touch so the kebab is an easy tap target.
@@ -492,13 +455,4 @@ const styles = StyleSheet.create((theme) => ({
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
// The kebab's box without its offsets: the archive glyph fills its view box, so it needs
// none of the nudging the three dots do. The kebab's marginLeft is the gap between them.
archiveButton: {
width: ARCHIVE_BUTTON_SIZE,
height: ARCHIVE_BUTTON_SIZE,
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
},
}));
@@ -29,11 +29,7 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
import { DensityIcon } from "@/components/ui/density-icon";
import { SCRIM_WIDTH, TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import {
ARCHIVE_BUTTON_SIZE,
SidebarWorkspaceArchiveButton,
} from "@/components/sidebar/sidebar-workspace-menu";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
const needsInputColorMapping = (theme: Theme) => ({
@@ -160,11 +156,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
)}
<View style={styles.workspaceContentColumn}>
<View style={styles.workspaceTitleRow}>
<Text
style={workspaceBranchTextStyle}
numberOfLines={1}
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspaceLabel}
</Text>
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
@@ -338,8 +330,6 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
position: "absolute",
top: 0,
right: 0,
flexDirection: "row",
alignItems: "flex-start",
},
}));
@@ -380,7 +370,6 @@ export function resolveTrailingActionVisibility({
}): {
trailingPresentation: SidebarWorkspaceTrailingPresentation;
showKebab: boolean;
showArchiveButton: boolean;
showScrim: boolean;
renderSlot: boolean;
reserveSlotWidth: boolean;
@@ -395,9 +384,6 @@ export function resolveTrailingActionVisibility({
return {
trailingPresentation,
showKebab,
// One-click archive is a pointer affordance: on touch it would sit a few pixels from the
// kebab and turn a miss into an archive.
showArchiveButton: showKebab && isHovered && !isTouchPlatform,
// The scrim paints the row's own hover background, so it can only be drawn on a hovered
// row — over an unhovered one the gradient fades to the wrong color. That is also why
// touch, which shows the kebab without ever hovering, never gets one.
@@ -446,46 +432,23 @@ export function SidebarWorkspaceTrailingActionBase({
);
}
// The archive button plus the kebab's marginLeft, which is the gap between them.
const ARCHIVE_BUTTON_SCRIM_WIDTH = ARCHIVE_BUTTON_SIZE + 2;
export function SidebarWorkspaceTrailingActionOverlay({
visible,
scrimBackdrop,
workspaceKey,
onArchiveButtonPress,
children,
}: {
visible: boolean;
/** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */
scrimBackdrop?: SidebarSurfaceBackdrop;
workspaceKey: string;
/**
* Shows the archive button left of the kebab. It overlays the title like the kebab does, so
* the scrim widens to cover it rather than the slot reserving its width.
*/
onArchiveButtonPress?: () => void;
children: ReactNode;
}) {
if (!visible || !children) return null;
return (
<>
{scrimBackdrop ? (
<TrailingActionScrim
backdrop={scrimBackdrop}
width={onArchiveButtonPress ? SCRIM_WIDTH + ARCHIVE_BUTTON_SCRIM_WIDTH : SCRIM_WIDTH}
testID="sidebar-workspace-trailing-scrim"
/>
<TrailingActionScrim backdrop={scrimBackdrop} testID="sidebar-workspace-trailing-scrim" />
) : null}
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>
{onArchiveButtonPress ? (
<SidebarWorkspaceArchiveButton
workspaceKey={workspaceKey}
onArchive={onArchiveButtonPress}
/>
) : null}
{children}
</View>
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
</>
);
}
@@ -404,7 +404,6 @@ function WorkspaceRowTrailingActions({
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
@@ -431,8 +430,6 @@ function WorkspaceRowTrailingActions({
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
+31 -50
View File
@@ -35,8 +35,6 @@ export interface ToastShowOptions {
durationMs?: number | null;
nativeAndroid?: boolean;
testID?: string;
/** Runs once when this toast leaves the screen: timed out, dismissed, or replaced. */
onDismiss?: () => void;
}
export interface ToastState {
@@ -47,7 +45,6 @@ export interface ToastState {
variant: ToastVariant;
durationMs: number | null;
testID?: string;
onDismiss?: () => void;
}
/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */
@@ -81,56 +78,40 @@ export function useToastHost(): {
dismiss: () => void;
} {
const { t } = useTranslation();
const [toast, setToastState] = useState<ToastState | null>(null);
const toastRef = useRef<ToastState | null>(null);
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
const setToast = useCallback((next: ToastState | null) => {
const previous = toastRef.current;
if (previous === next) return;
toastRef.current = next;
setToastState(next);
previous?.onDismiss?.();
const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => {
const nativeMessage = typeof content === "string" ? content.trim() : null;
if (!content || nativeMessage === "") {
return noopDismiss;
}
const variant = options?.variant ?? "default";
const durationMs = options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs;
const nativeAndroid = options?.nativeAndroid ?? false;
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration =
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
return noopDismiss;
}
idRef.current += 1;
const id = idRef.current;
setToast({
id,
content,
nativeMessage,
icon: options?.icon,
variant,
durationMs,
testID: options?.testID,
});
return () => setToast((current) => (current?.id === id ? null : current));
}, []);
const show = useCallback(
(content: ReactNode, options?: ToastShowOptions): DismissToast => {
const nativeMessage = typeof content === "string" ? content.trim() : null;
if (!content || nativeMessage === "") {
return noopDismiss;
}
const variant = options?.variant ?? "default";
const durationMs =
options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs;
const nativeAndroid = options?.nativeAndroid ?? false;
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration =
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
return noopDismiss;
}
idRef.current += 1;
const id = idRef.current;
setToast({
id,
content,
nativeMessage,
icon: options?.icon,
variant,
durationMs,
testID: options?.testID,
onDismiss: options?.onDismiss,
});
return () => {
if (toastRef.current?.id === id) setToast(null);
};
},
[setToast],
);
const api = useMemo<ToastApi>(
() => ({
show,
@@ -146,7 +127,7 @@ export function useToastHost(): {
const dismiss = useCallback(() => {
setToast(null);
}, [setToast]);
}, []);
return { api, toast, dismiss };
}
@@ -12,19 +12,10 @@ import {
resolveControlSizing,
switchGeometryForLayout,
getControlInteractionPhase,
TOOLBAR_CONTROL_GAP,
TOOLBAR_CONTROL_GAPS,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_GLYPH_SIZES,
TOOLBAR_DISC_SIZE,
TOOLBAR_DISC_SIZES,
TOOLBAR_GLYPH_SIZES,
TOOLBAR_RINGS,
toolbarGlyphColor,
toolbarGlyphsFor,
toolbarGlyphTone,
toolbarSlot,
TOUCH_TARGET,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -118,62 +109,6 @@ describe("control sizing gate", () => {
});
});
describe("toolbar glyphs and spacing", () => {
it("spaces touch toolbar controls by what they paint and keeps the pointer's 4px gap", () => {
expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 20, pointer: 4 });
expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 20, md: 4 });
// A 20pt glyph's disc-sized frame reaches 8 into the gap on each side.
expect(toolbarSlot(20)).toEqual({
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
marginHorizontal: { xs: -8, md: 0 },
});
});
it("paints touch toolbar glyphs at the header icons' 20pt and 1.5px stroke and leaves pointer unchanged", () => {
const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" };
expect(toolbarGlyphsFor("touch").icon).toEqual({
size: 20,
strokeWidth: 1.5,
tone: "foreground",
});
expect(toolbarGlyphsFor("pointer")).toEqual({ icon: pointerGlyph, label: pointerGlyph });
expect(TOOLBAR_GLYPH_SIZES.icon).toEqual({ xs: 20, md: 16 });
expect(toolbarGlyphsFor("touch").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.touch);
expect(toolbarGlyphsFor("pointer").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.pointer);
// A disc's solid glyph stays at the `md` size so the stop square does not fill its disc.
expect(TOOLBAR_DISC_GLYPH_SIZES).toEqual({ touch: 20, pointer: 16 });
});
it("keeps a touch glyph beside a label at the label's 20pt scale", () => {
expect(toolbarGlyphsFor("touch").label).toEqual({
size: 20,
strokeWidth: 1.5,
tone: "foreground",
});
expect(TOOLBAR_GLYPH_SIZES.label).toEqual({ xs: 20, md: 16 });
});
it("sizes the touch context ring to the toolbar glyph's ink and keeps the pointer ring", () => {
expect(TOOLBAR_RINGS).toEqual({
touch: { size: 18, strokeWidth: 2 },
pointer: { size: 16, strokeWidth: 2 },
});
});
it("brightens a muted toolbar glyph under a hovering pointer", () => {
const pointer = toolbarGlyphsFor("pointer").icon;
const touch = toolbarGlyphsFor("touch").icon;
expect(toolbarGlyphTone(pointer, false)).toBe("muted");
expect(toolbarGlyphTone(pointer, true)).toBe("foreground");
expect(toolbarGlyphTone(touch, false)).toBe("foreground");
const colors = { foreground: "#fafafa", foregroundMuted: "#a1a5a4" };
const palette = { colors } as unknown as Theme;
expect(toolbarGlyphColor(palette, "foreground")).toBe("#fafafa");
expect(toolbarGlyphColor(palette, "muted")).toBe("#a1a5a4");
});
});
describe("control geometry", () => {
it("keeps resting control borders transparent while preserving border geometry", () => {
const geometry = createControlGeometry(theme);
@@ -1,5 +1,5 @@
import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, ICON_STROKE_WIDTH, type Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
bySizing,
controlSizingForLayout,
@@ -87,63 +87,20 @@ export const ICON_BUTTON_SIZES = {
} satisfies Record<"large" | "small", SizingValue>;
/**
* The height of a dense toolbar row, such as the composer's. Touch-sized it is one touch target;
* its controls are disc-sized and centre in it.
* A control in a dense toolbar row, such as the composer's: an icon-only square or a labelled
* pill. Touch-sized it is one touch target.
*/
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
/**
* The disc of a filled toolbar control, such as the composer's send and stop, and the frame and
* highlight of every other toolbar control (`toolbarSlot`). Pointer-sized it fills the row.
* 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);
/**
* The space between adjacent toolbar controls. Touch-sized it is the visible space between what
* the controls paint, because each control lays out at its painted width (`toolbarSlot`).
*/
export const TOOLBAR_CONTROL_GAPS = { touch: 20, pointer: 4 } satisfies SizingValue;
export const TOOLBAR_CONTROL_GAP = bySizing(TOOLBAR_CONTROL_GAPS);
/**
* A toolbar control's frame: a disc-sized circle, which is also its fully rounded highlight.
* Touch-sized it lays out at the width of the glyph it paints, `shapeWidth`, and the circle reaches
* into the gaps on either side, so the row spaces what you see. Pointer-sized it is the square
* control.
*/
export function toolbarSlot(shapeWidth: number): {
width: LayoutValue;
height: LayoutValue;
marginHorizontal: LayoutValue;
} {
return {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
marginHorizontal: bySizing({
touch: (shapeWidth - TOOLBAR_DISC_SIZES.touch) / 2,
pointer: 0,
}),
};
}
/**
* A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. Touch-sized
* its padding reaches into the gaps, so its content is what the row spaces.
*/
export function toolbarContentSlot(padding: number): {
height: LayoutValue;
paddingHorizontal: number;
marginHorizontal: LayoutValue;
} {
return {
height: TOOLBAR_DISC_SIZE,
paddingHorizontal: padding,
marginHorizontal: bySizing({ touch: -padding, pointer: 0 }),
};
}
/**
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
* text, so each step is larger.
@@ -155,81 +112,6 @@ export function iconSizeFor(size: IconSize): SizingValue {
return { touch: TOUCH_ICON_SIZE[size], pointer: ICON_SIZE[size] };
}
/**
* The glyph a toolbar control paints. Touch-sized every toolbar glyph matches the header icons:
* 20pt at the app's 1.5px stroke, resting at `foreground`. Pointer-sized it is the `md` glyph at
* the same stroke and rests muted until hovered.
*/
export type ToolbarGlyphTone = "foreground" | "muted";
export interface ToolbarGlyph {
size: number;
strokeWidth: number;
/** The tone at rest; a hovering pointer brightens it to `foreground`. */
tone: ToolbarGlyphTone;
}
/** `icon` is an icon-only control's glyph; `label` is a glyph paired with a label. */
export interface ToolbarGlyphs {
icon: ToolbarGlyph;
label: ToolbarGlyph;
}
const POINTER_TOOLBAR_GLYPH: ToolbarGlyph = {
size: ICON_SIZE.md,
strokeWidth: ICON_STROKE_WIDTH,
tone: "muted",
};
const TOOLBAR_GLYPHS: Record<ControlSizing, ToolbarGlyphs> = {
touch: {
icon: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" },
label: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" },
},
pointer: { icon: POINTER_TOOLBAR_GLYPH, label: POINTER_TOOLBAR_GLYPH },
};
export function toolbarGlyphsFor(sizing: ControlSizing): ToolbarGlyphs {
return TOOLBAR_GLYPHS[sizing];
}
/** The slot each toolbar glyph sits in, as stylesheet values. */
export const TOOLBAR_GLYPH_SIZES = {
icon: bySizing({ touch: TOOLBAR_GLYPHS.touch.icon.size, pointer: POINTER_TOOLBAR_GLYPH.size }),
label: bySizing({ touch: TOOLBAR_GLYPHS.touch.label.size, pointer: POINTER_TOOLBAR_GLYPH.size }),
} satisfies Record<keyof ToolbarGlyphs, LayoutValue>;
/**
* The context ring in a toolbar row. A full circle fills its box while an outline glyph's ink
* sits inset, so touch-sized the ring is 18pt, the height of a 20pt glyph's ink. Its 2px stroke
* is half a pixel over the glyphs' so the thin circle does not read lighter than them.
* Pointer-sized it is the `md` glyph.
*/
export interface ToolbarRing {
size: number;
strokeWidth: number;
}
export const TOOLBAR_RINGS = {
touch: { size: 18, strokeWidth: 2 },
pointer: { size: ICON_SIZE.md, strokeWidth: 2 },
} satisfies Record<ControlSizing, ToolbarRing>;
export function toolbarGlyphTone(glyph: ToolbarGlyph, hovered: boolean): ToolbarGlyphTone {
return hovered ? "foreground" : glyph.tone;
}
export function toolbarGlyphColor(theme: Theme, tone: ToolbarGlyphTone): string {
return tone === "foreground" ? theme.colors.foreground : theme.colors.foregroundMuted;
}
/**
* The solid glyph inside a toolbar disc, such as the stop square. It stays at the `md` glyph
* size: at the toolbar glyph size the square would fill half its disc. Send's outline arrow
* takes the toolbar glyph.
*/
export const TOOLBAR_DISC_GLYPH_SIZES = iconSizeFor("md");
/** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */
export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number {
return CONTROL_SIZES[size][controlSizingForLayout(isCompact)];
@@ -1,61 +0,0 @@
import React, { act, useMemo } from "react";
import { createRoot, type Root } from "react-dom/client";
import { View } from "react-native";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { TailEllipsisText } from "./index";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
});
const TEXT_STYLE = { fontSize: 16, fontFamily: "monospace" } as const;
function LabelRow({ text, width }: { text: string; width: number }) {
const rowStyle = useMemo(() => ({ width, flexDirection: "row" as const }), [width]);
return (
<View style={rowStyle}>
<TailEllipsisText text={text} style={TEXT_STYLE} testID="label" />
</View>
);
}
function renderLabel(text: string, width: number): void {
act(() => root.render(<LabelRow text={text} width={width} />));
}
function shownLabel(): { text: string; visible: boolean } {
const label = host.querySelector<HTMLElement>('[data-testid="label"]');
if (!label) throw new Error("label did not render");
return { text: label.textContent ?? "", visible: getComputedStyle(label).opacity !== "0" };
}
it("shows a replacement label that shares its shortened prefixes with the previous one", async () => {
// Wide enough for "Ten second…" in monospace, too narrow for either full label.
const width = 12 * 10;
renderLabel("Ten second stream", width);
await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true }));
renderLabel("Ten second thread", width);
await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true }));
});
it("shows a replacement label as wide as the previous one", async () => {
renderLabel("Opus 4.7", 400);
await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.7", visible: true }));
renderLabel("Opus 4.8", 400);
await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.8", visible: true }));
});
@@ -1,145 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { Text, View, type LayoutChangeEvent, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve";
interface MeasuredWidths {
text: string;
full?: number;
candidates: (number | undefined)[];
}
/**
* One line of text that shrinks with its row and, when it does not fit, ends on a whole word
* and an ellipsis ("Ten second…"), never on a space. It measures itself, so it stays invisible
* for the frame before its widths are known.
*/
export function TailEllipsisText({
text,
style,
testID,
}: {
text: string;
/** Text styles; the component owns its layout. */
style: StyleProp<TextStyle>;
testID?: string;
}) {
const candidates = useMemo(() => tailEllipsisCandidates(text), [text]);
const [boxWidth, setBoxWidth] = useState<number | undefined>(undefined);
const [widths, setWidths] = useState<MeasuredWidths>({ text, candidates: [] });
const current = widths.text === text ? widths : { text, candidates: [] };
const onBoxLayout = useCallback((event: LayoutChangeEvent) => {
const width = readMeasuredWidth(event);
if (width !== null) setBoxWidth(width);
}, []);
const measure = useCallback(
(slot: "full" | number) => (event: LayoutChangeEvent) => {
const width = readMeasuredWidth(event);
if (width === null) return;
setWidths((previous) => {
const base = previous.text === text ? previous : { text, candidates: [] };
if (slot === "full") return { ...base, full: width };
const next = [...base.candidates];
next[slot] = width;
return { ...base, candidates: next };
});
},
[text],
);
const decision = resolveTailEllipsis({
candidates,
measurements: { boxWidth, fullWidth: current.full, candidateWidths: current.candidates },
});
const shownText = decision.kind === "truncated" ? decision.text : text;
return (
<View style={styles.box} onLayout={onBoxLayout}>
{/* Sizes the box: as wide as the text, or as the row allows. */}
<Text
style={[style, styles.sizer]}
numberOfLines={1}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{text}
</Text>
<View style={styles.shown} pointerEvents="none">
<Text
style={[style, styles.shownText, decision.kind === "measuring" && styles.measuring]}
numberOfLines={1}
ellipsizeMode="tail"
testID={testID}
>
{shownText}
</Text>
</View>
{/* Keyed by the text, so new text remounts the measurer and every width is measured again.
A retained node whose size does not change emits no new layout event. */}
<View
key={text}
style={styles.measurer}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Text style={[style, styles.measured]} numberOfLines={1} onLayout={measure("full")}>
{text}
</Text>
{candidates.map((candidate, index) => (
<Text
key={candidate}
style={[style, styles.measured]}
numberOfLines={1}
onLayout={measure(index)}
>
{candidate}
</Text>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
// A row, so the absolute measurer below lays its text out at full width on native too.
box: {
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
},
sizer: {
minWidth: 0,
flexShrink: 1,
opacity: 0,
},
shown: {
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
flexDirection: "row",
alignItems: "center",
},
shownText: {
minWidth: 0,
flexShrink: 1,
},
measuring: {
opacity: 0,
},
measurer: {
position: "absolute",
left: 0,
top: 0,
flexDirection: "row",
alignItems: "flex-start",
opacity: 0,
},
measured: {
flexShrink: 0,
},
});
@@ -1,64 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve";
const candidates = tailEllipsisCandidates("Ten second stream");
const candidateWidths = [34, 80];
describe("tail ellipsis", () => {
it("cuts at word boundaries, so no whitespace sits before the ellipsis", () => {
expect(candidates).toEqual(["Ten…", "Ten second…"]);
expect(tailEllipsisCandidates(" GPT-5.1 Codex ")).toEqual(["GPT-5.1…"]);
expect(tailEllipsisCandidates("claude-opus-4-5")).toEqual([]);
});
it("shows the full text while it fits", () => {
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 132, fullWidth: 132.3, candidateWidths },
}),
).toEqual({ kind: "full" });
});
it("keeps the most words that fit with the ellipsis", () => {
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 98, fullWidth: 132, candidateWidths },
}),
).toEqual({ kind: "truncated", text: "Ten second…" });
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 60, fullWidth: 132, candidateWidths },
}),
).toEqual({ kind: "truncated", text: "Ten…" });
});
it("lets the platform cut inside a first word too long for the box", () => {
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 20, fullWidth: 132, candidateWidths },
}),
).toEqual({ kind: "clipped" });
expect(
resolveTailEllipsis({
candidates: [],
measurements: { boxWidth: 60, fullWidth: 132, candidateWidths: [] },
}),
).toEqual({ kind: "clipped" });
});
it("decides nothing until the widths it needs are measured", () => {
expect(resolveTailEllipsis({ candidates, measurements: { candidateWidths } })).toEqual({
kind: "measuring",
});
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 98, fullWidth: 132, candidateWidths: [34] },
}),
).toEqual({ kind: "measuring" });
});
});
@@ -1,47 +0,0 @@
const ELLIPSIS = "…";
/**
* The word-boundary truncations of `text`, shortest first: "Ten second stream" gives "Ten…" and
* "Ten second…". Cutting at a word keeps whitespace out from before the ellipsis.
*/
export function tailEllipsisCandidates(text: string): string[] {
const words = text.trim().split(/\s+/);
return words.slice(1).map((_, index) => `${words.slice(0, index + 1).join(" ")}${ELLIPSIS}`);
}
export type TailEllipsis =
| { kind: "measuring" }
| { kind: "full" }
| { kind: "truncated"; text: string }
/** Not even the first word fits: the platform ellipsis cuts inside it, where no space can sit. */
| { kind: "clipped" };
/** Widths in points, measured from the rendered text. */
export interface TailEllipsisMeasurements {
/** The space the text may take, up to its full width. */
boxWidth?: number;
fullWidth?: number;
/** One per `tailEllipsisCandidates` entry, in the same order. */
candidateWidths: readonly (number | undefined)[];
}
/** Sub-point slack for widths the layout rounds differently from the text it measures. */
const ROUNDING_SLACK = 0.5;
export function resolveTailEllipsis(input: {
candidates: readonly string[];
measurements: TailEllipsisMeasurements;
}): TailEllipsis {
const { boxWidth, fullWidth, candidateWidths } = input.measurements;
if (boxWidth === undefined || fullWidth === undefined) return { kind: "measuring" };
if (fullWidth <= boxWidth + ROUNDING_SLACK) return { kind: "full" };
if (input.candidates.some((_, index) => candidateWidths[index] === undefined)) {
return { kind: "measuring" };
}
for (let index = input.candidates.length - 1; index >= 0; index -= 1) {
if ((candidateWidths[index] ?? Infinity) <= boxWidth) {
return { kind: "truncated", text: input.candidates[index] };
}
}
return { kind: "clipped" };
}
@@ -6,18 +6,9 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { Theme } from "@/styles/theme";
export const SCRIM_WIDTH = 48;
// The fade is a fixed length so a wider scrim only adds solid surface under its actions.
const SCRIM_FADE_WIDTH = SCRIM_WIDTH * 0.55;
const SCRIM_SOLID_OFFSET = "55%";
function TrailingActionScrimSvg({
gradientId,
color,
solidOffset,
}: {
gradientId: string;
color: string;
solidOffset: string;
}) {
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
@@ -25,7 +16,7 @@ function TrailingActionScrimSvg({
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset={solidOffset} stopColor={color} stopOpacity={1} />
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
</SvgLinearGradient>
</Defs>
@@ -48,22 +39,17 @@ const backdropColorMappings: Record<SurfaceBackdrop, (theme: Theme) => { color:
/** Fades trailing content into the surface beneath an absolutely overlaid action. */
export function TrailingActionScrim({
backdrop,
width = SCRIM_WIDTH,
testID,
}: {
backdrop: SurfaceBackdrop;
/** Covers more than one trailing action; the fade keeps its length. */
width?: number;
testID?: string;
}) {
// React-generated ids contain characters that are invalid inside SVG fragment references.
const gradientId = `trailing-action-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const solidOffset = `${Math.round((SCRIM_FADE_WIDTH / width) * 100)}%`;
return (
<View style={styles.scrim(width)} pointerEvents="none" testID={testID}>
<View style={styles.scrim} pointerEvents="none" testID={testID}>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
solidOffset={solidOffset}
uniProps={backdropColorMappings[backdrop]}
/>
</View>
@@ -71,11 +57,11 @@ export function TrailingActionScrim({
}
const styles = StyleSheet.create({
scrim: (width: number) => ({
scrim: {
position: "absolute",
top: 0,
bottom: 0,
right: 0,
width,
}),
width: SCRIM_WIDTH,
},
});
@@ -2,7 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import type { AgentControlIcon } from "@/agent-controls/icons";
@@ -42,13 +42,10 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
},
ref,
) {
const { glyphs } = useComposerControlLayout();
const glyphRole = showToolbarLabel ? "label" : "icon";
const glyph = glyphs[glyphRole];
const { glyphSize } = useComposerControlLayout();
const isSheet = surface === "sheet";
const toolbarIconColor =
glyph.tone === "foreground" ? styles.iconColorForeground.color : styles.iconColor.color;
const resolvedIconColor = iconColor ?? (isSheet ? styles.iconColor.color : toolbarIconColor);
const resolvedGlyphSize = isSheet ? 16 : glyphSize;
const resolvedIconColor = iconColor ?? styles.iconColor.color;
const showValue = isSheet || showToolbarLabel;
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
@@ -76,11 +73,11 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
>
{isSheet ? (
<View style={styles.sheetGlyph}>
<Icon size={16} color={resolvedIconColor} />
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
</View>
) : (
<ComposerToolbarGlyph role={glyphRole}>
<Icon size={glyph.size} strokeWidth={glyph.strokeWidth} color={resolvedIconColor} />
<ComposerToolbarGlyph size={resolvedGlyphSize}>
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
</ComposerToolbarGlyph>
)}
{isSheet ? (
@@ -100,17 +97,18 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
const styles = StyleSheet.create((theme) => ({
toolbarControl: {
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
backgroundColor: "transparent",
},
toolbarIconOnly: {
...toolbarSlot(TOUCH_ICON_SIZE.md),
width: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -162,7 +160,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
pressed: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
},
disabled: {
opacity: 0.5,
@@ -170,7 +168,4 @@ const styles = StyleSheet.create((theme) => ({
iconColor: {
color: theme.colors.foregroundMuted,
},
iconColorForeground: {
color: theme.colors.foreground,
},
}));
@@ -1,22 +1,10 @@
import type { ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { TOOLBAR_GLYPH_SIZES, type ToolbarGlyphs } from "@/components/ui/control-geometry";
import { StyleSheet, View } from "react-native";
/**
* The slot a composer toolbar control's glyph sits in; see `toolbarGlyphsFor`. `label` when the
* glyph sits beside a label, `icon` when the glyph is the whole control.
*/
export function ComposerToolbarGlyph({
role,
children,
}: {
role: keyof ToolbarGlyphs;
children: ReactNode;
}) {
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
return (
<View
style={role === "label" ? styles.labelSlot : styles.iconSlot}
style={size >= 20 ? styles.touch : styles.pointer}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
@@ -26,9 +14,19 @@ export function ComposerToolbarGlyph({
);
}
const slot = { flexShrink: 0, alignItems: "center", justifyContent: "center" } as const;
const styles = StyleSheet.create({
iconSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.icon, height: TOOLBAR_GLYPH_SIZES.icon },
labelSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.label, height: TOOLBAR_GLYPH_SIZES.label },
pointer: {
width: 16,
height: 16,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
touch: {
width: 20,
height: 20,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
});
@@ -61,16 +61,7 @@ import {
resolveAgentModelSelection,
} from "@/composer/agent-controls/utils";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
controlSizingForLayout,
TOOLBAR_CONTROL_GAP,
bySizing,
toolbarGlyphColor,
type ToolbarGlyphs,
TOUCH_ICON_SIZE,
toolbarContentSlot,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { controlSizingForLayout, TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { DensityIcon } from "@/components/ui/density-icon";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import { useToast } from "@/contexts/toast-context";
@@ -88,10 +79,7 @@ import {
type ComposerControlDensity,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
import {
ComposerControlLayoutProvider,
useComposerControlLayout,
} from "@/composer/agent-controls/layout-context";
import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { CompactModelSheet } from "@/composer/agent-controls/model-sheet";
@@ -231,10 +219,10 @@ function getFeatureIconColor(
green: { 400: string };
yellow: { 400: string };
},
restColor: string,
foregroundMuted: string,
): string {
if (!isFeatureActive(feature)) {
return restColor;
return foregroundMuted;
}
switch (getFeatureHighlightColor(feature.id)) {
@@ -245,7 +233,7 @@ function getFeatureIconColor(
case "yellow":
return palette.yellow[400];
default:
return restColor;
return foregroundMuted;
}
}
@@ -597,7 +585,7 @@ function ControlledAgentControls({
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
const layoutContextValue = useMemo(
() => ({
glyphs: toolbarGeometry.glyphs,
glyphSize: toolbarGeometry.glyphSize,
presentation,
}),
[presentation, toolbarGeometry],
@@ -811,7 +799,7 @@ function ControlledAgentControls({
renderThinkingOption={renderThinkingOption}
modeControl={modeControl}
presentation={presentation}
glyphs={layoutContextValue.glyphs}
glyphSize={layoutContextValue.glyphSize}
activeSheet={activeSheet}
handleOpenSheet={handleOpenSheet}
handleCloseSheet={handleCloseSheet}
@@ -850,7 +838,7 @@ function ControlledAgentControls({
handleOpenChange={handleSheetOpenChange}
renderThinkingOption={renderThinkingOption}
modeControl={modeControl}
glyphs={layoutContextValue.glyphs}
glyphSize={layoutContextValue.glyphSize}
showLabels={presentation.showModelLabel}
modelSelectorServerId={modelSelectorServerId}
canSwitchProvider={Boolean(onSelectProviderAndModel)}
@@ -912,7 +900,7 @@ interface DesktopAgentControlsContentProps {
}) => ReactElement;
modeControl?: AgentModeControlValue | null;
presentation: ComposerControlPresentation;
glyphs: ToolbarGlyphs;
glyphSize: number;
activeSheet: ActiveSheet;
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
handleCloseSheet: () => void;
@@ -969,15 +957,15 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
renderThinkingOption,
modeControl,
presentation,
glyphs,
glyphSize,
activeSheet,
handleOpenSheet,
handleCloseSheet,
modelSelectorServerId,
} = props;
const modelToolbar = useMemo(
() => ({ glyph: glyphs.label, showCaret: presentation.showCarets }),
[glyphs, presentation.showCarets],
() => ({ glyphSize, showCaret: presentation.showCarets }),
[glyphSize, presentation.showCarets],
);
const featuresSheetHeader = useMemo<SheetHeader>(
() => ({ title: t("agentControls.features.title") }),
@@ -1098,12 +1086,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
accessibilityLabel={t("agentControls.features.open")}
testID="agent-controls-features"
>
<ComposerToolbarGlyph role="icon">
<Settings2
size={glyphs.icon.size}
strokeWidth={glyphs.icon.strokeWidth}
color={toolbarGlyphColor(theme, glyphs.icon.tone)}
/>
<ComposerToolbarGlyph size={glyphSize}>
<Settings2 size={glyphSize} color={theme.colors.foregroundMuted} />
</ComposerToolbarGlyph>
</Pressable>
<AdaptiveModalSheet
@@ -1178,7 +1162,7 @@ interface SheetAgentControlsContentProps {
onPress: () => void;
}) => ReactElement;
modeControl?: AgentModeControlValue | null;
glyphs: ToolbarGlyphs;
glyphSize: number;
showLabels: boolean;
modelSelectorServerId: string | null;
canSwitchProvider: boolean;
@@ -1218,7 +1202,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
handleOpenChange,
renderThinkingOption,
modeControl,
glyphs,
glyphSize,
showLabels,
modelSelectorServerId,
canSwitchProvider,
@@ -1307,7 +1291,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
onRetryProvider={onRetryModelProvider}
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
glyphs={glyphs}
glyphSize={glyphSize}
showLabels={showLabels}
canSwitchProvider={canSwitchProvider}
>
@@ -1332,8 +1316,6 @@ function DesktopFeatureItem({
onActionComplete?: () => void;
}) {
const { theme } = useUnistyles();
const { glyphs } = useComposerControlLayout();
const restColor = toolbarGlyphColor(theme, glyphs.icon.tone);
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null);
@@ -1374,7 +1356,11 @@ function DesktopFeatureItem({
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
icon={FeatureIcon}
iconColor={getFeatureIconColor(feature, theme.colors.palette, restColor)}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar"
label={feature.label}
showToolbarLabel={false}
@@ -1405,7 +1391,11 @@ function DesktopFeatureItem({
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(feature, theme.colors.palette, restColor)}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar"
label={feature.label}
value={selectedOption?.label ?? feature.label}
@@ -1982,20 +1972,17 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
// Clips pickers that run out of room. Touch-sized the pickers' pills and circles reach 8 past
// what the row spaces, so the clip reaches as far.
gap: theme.spacing[1],
overflow: "hidden",
paddingHorizontal: bySizing({ touch: theme.spacing[2], pointer: 0 }),
marginHorizontal: bySizing({ touch: -theme.spacing[2], pointer: 0 }),
},
modeBadge: {
height: TOOLBAR_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
},
modelControl: {
minWidth: 0,
@@ -2007,7 +1994,8 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
modeIconBadge: {
...toolbarSlot(TOUCH_ICON_SIZE.md),
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 0,
@@ -2019,7 +2007,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
modeBadgePressed: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
},
disabledBadge: {
opacity: 0.5,
@@ -1,17 +1,16 @@
import { createContext, useContext, type ReactNode } from "react";
import { toolbarGlyphsFor, type ToolbarGlyphs } from "@/components/ui/control-geometry";
import {
resolveComposerControlPresentation,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
interface ComposerControlLayoutValue {
glyphs: ToolbarGlyphs;
glyphSize: number;
presentation: ComposerControlPresentation;
}
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
glyphs: toolbarGlyphsFor("pointer"),
glyphSize: 16,
presentation: resolveComposerControlPresentation("full"),
};
@@ -3,7 +3,6 @@ import {
composerToolbarGeometry,
resolveComposerControlDensity,
resolveComposerControlPresentation,
resolveModelTriggerLabels,
} from "./layout";
const POINTER = composerToolbarGeometry("pointer");
@@ -175,29 +174,21 @@ describe("composer control layout", () => {
).toBe("condensed");
});
it("gives every toolbar control one shell and one glyph per role and control sizing", () => {
const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" };
it("gives every toolbar control one shell and one glyph per control sizing", () => {
expect(POINTER).toEqual({
controlSize: 28,
glyphs: { icon: pointerGlyph, label: pointerGlyph },
glyphSize: 16,
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
});
expect(TOUCH).toMatchObject({
controlSize: 20,
glyphs: {
icon: { size: 20, strokeWidth: 1.5, tone: "foreground" },
label: { size: 20, strokeWidth: 1.5, tone: "foreground" },
},
controlGap: 20,
});
expect(TOUCH).toMatchObject({ controlSize: 44, glyphSize: 20 });
});
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
const input = {
availableWidth: 400,
availableWidth: 420,
currentDensity: "full" as const,
controls: {
hasModel: true,
@@ -213,8 +204,8 @@ describe("composer control layout", () => {
});
describe("touch toolbar on an iPad in portrait, sidebar docked", () => {
// Codex: model, thinking, mode and two toggle features. Touch controls lay out at what they
// paint, 20pt apart, so icon-only thinking, mode and features need 200.
// Codex: model, thinking, mode and two toggle features. The measured toolbar is 272 wide
// with an empty draft and 224 once an attachment brings in the send button.
const controls = {
hasModel: true,
hasThinking: true,
@@ -226,7 +217,7 @@ describe("composer control layout", () => {
it("keeps the model family label with icon-only controls on an empty draft", () => {
expect(
resolveComposerControlDensity({
availableWidth: 280,
availableWidth: 272,
currentDensity: "full",
geometry: TOUCH,
controls,
@@ -234,21 +225,10 @@ describe("composer control layout", () => {
).toBe("tight");
});
it("keeps the icon-only controls once an attachment brings in send", () => {
it("folds thinking, mode and features into the model control once an attachment narrows it", () => {
expect(
resolveComposerControlDensity({
availableWidth: 240,
currentDensity: "tight",
geometry: TOUCH,
controls,
}),
).toBe("tight");
});
it("folds thinking, mode and features into the model control below their width", () => {
expect(
resolveComposerControlDensity({
availableWidth: 180,
availableWidth: 224,
currentDensity: "tight",
geometry: TOUCH,
controls,
@@ -259,7 +239,7 @@ describe("composer control layout", () => {
it("drops the model label only after the controls are folded", () => {
expect(
resolveComposerControlDensity({
availableWidth: 64,
availableWidth: 120,
currentDensity: "compact",
geometry: TOUCH,
controls,
@@ -283,58 +263,8 @@ describe("composer control layout", () => {
};
expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact");
// The thinking label is not budgeted: it gives way before the model family label does.
expect(resolveComposerControlDensity({ ...input, availableWidth: 96 })).toBe("compact");
expect(resolveComposerControlDensity({ ...input, availableWidth: 64 })).toBe("iconOnly");
});
});
describe("folded model control labels", () => {
it("shows the thinking label while the model label fits beside it in full", () => {
expect(
resolveModelTriggerLabels({
hasThinking: true,
measurements: { availableWidth: 191, fullWidth: 190 },
}),
).toBe("modelAndThinking");
});
it("hides the thinking label when rounded widths tie", () => {
// On the web, 203.14 of labels and a 203 frame both report 203.
expect(
resolveModelTriggerLabels({
hasThinking: true,
measurements: { availableWidth: 203, fullWidth: 203 },
}),
).toBe("model");
});
it("hides the thinking label before the model label would truncate", () => {
expect(
resolveModelTriggerLabels({
hasThinking: true,
measurements: { availableWidth: 189.5, fullWidth: 190 },
}),
).toBe("model");
expect(
resolveModelTriggerLabels({
hasThinking: true,
measurements: { availableWidth: 90, fullWidth: 190 },
}),
).toBe("model");
});
it("decides nothing until both widths are measured", () => {
expect(resolveModelTriggerLabels({ hasThinking: true, measurements: {} })).toBe("measuring");
expect(
resolveModelTriggerLabels({ hasThinking: true, measurements: { availableWidth: 300 } }),
).toBe("measuring");
expect(resolveModelTriggerLabels({ hasThinking: true, measurements: { fullWidth: 120 } })).toBe(
"measuring",
);
});
it("needs no measurement without a thinking label", () => {
expect(resolveModelTriggerLabels({ hasThinking: false, measurements: {} })).toBe("model");
// The iPhone toolbar with an attachment: "GPT-6…" beside "Medium" still fits.
expect(resolveComposerControlDensity({ ...input, availableWidth: 159 })).toBe("compact");
expect(resolveComposerControlDensity({ ...input, availableWidth: 120 })).toBe("iconOnly");
});
});
@@ -1,9 +1,7 @@
import {
TOOLBAR_CONTROL_GAPS,
iconSizeFor,
TOOLBAR_CONTROL_SIZES,
toolbarGlyphsFor,
type ControlSizing,
type ToolbarGlyphs,
} from "@/components/ui/control-geometry";
/**
@@ -41,32 +39,23 @@ export interface ComposerControlPresentation {
showModelLabel: boolean;
}
/** What each toolbar control takes up in the row, which is what the density budget sums. */
export interface ComposerToolbarGeometry {
/** An icon-only control's width in the row. */
controlSize: number;
glyphs: ToolbarGlyphs;
glyphSize: number;
controlGap: number;
iconLabelGap: number;
/** A labelled control's padding on each side, as far as it takes room in the row. */
labelPadding: number;
caretSize: number;
}
/**
* The toolbar's measurements under a control sizing; see `resolveControlSizing`. Touch-sized a
* control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`), so an
* icon-only control takes its glyph's width and a picker's padding takes no room.
*/
/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
const glyphs = toolbarGlyphsFor(sizing);
const isTouch = sizing === "touch";
return {
controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer,
glyphs,
controlGap: TOOLBAR_CONTROL_GAPS[sizing],
controlSize: TOOLBAR_CONTROL_SIZES[sizing],
glyphSize: iconSizeFor("md")[sizing],
controlGap: 4,
iconLabelGap: 4,
labelPadding: isTouch ? 0 : 8,
labelPadding: 8,
caretSize: 14,
};
}
@@ -74,6 +63,8 @@ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarG
const DENSITY_HYSTERESIS = 12;
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
const MODEL_FAMILY_LABEL_WIDTH = 56;
/** A thinking label beside the folded model label, such as "Medium". */
const THINKING_LABEL_WIDTH = 48;
function normalizedFontScale(fontScale: number): number {
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
@@ -92,7 +83,7 @@ function estimateLabelWidth(label: string, fontScale: number): number {
/** A labelled control's fixed chrome: its padding, its glyph, and the gap to its label. */
function labelledChrome(geometry: ComposerToolbarGeometry): number {
return geometry.labelPadding * 2 + geometry.glyphs.label.size + geometry.iconLabelGap;
return geometry.labelPadding * 2 + geometry.glyphSize + geometry.iconLabelGap;
}
function resolveFeatureControlWidth(
@@ -156,16 +147,16 @@ function resolveTightFloor(
return sumControlWidths(widths, geometry);
}
/**
* One model control carrying the model family label. Its thinking label is not budgeted: it is
* the first label to go when the control runs out of room; see `resolveModelTriggerLabels`.
*/
/** One model control carrying the model family label and the thinking label. */
function resolveCompactFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale;
const thinkingLabel = controls.hasThinking
? geometry.iconLabelGap + THINKING_LABEL_WIDTH * fontScale
: 0;
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale + thinkingLabel;
}
function resolveDensityFloor(
@@ -260,33 +251,3 @@ export function resolveComposerControlPresentation(
): ComposerControlPresentation {
return PRESENTATIONS[density];
}
/**
* Which labels the folded model control shows. `measuring` until every width is known, so the
* control never paints a choice made without them.
*/
export type ModelTriggerLabels = "measuring" | "modelAndThinking" | "model";
/** Widths measured from the rendered model control, in points. */
export interface ModelTriggerMeasurements {
/** The width the control may take. */
availableWidth: number;
/** The control with its model and thinking labels side by side, untruncated. */
fullWidth: number;
}
/**
* The thinking label goes first: it shows only while the model label fits beside it in full, so
* the model label truncates only once the thinking label is gone. The web reports layout widths
* rounded to whole pixels, so equal widths can hide a fraction of a pixel of overflow; only a
* full width strictly below the available width is known to fit.
*/
export function resolveModelTriggerLabels(input: {
hasThinking: boolean;
measurements: Partial<ModelTriggerMeasurements>;
}): ModelTriggerLabels {
if (!input.hasThinking) return "model";
const { availableWidth, fullWidth } = input.measurements;
if (availableWidth === undefined || fullWidth === undefined) return "measuring";
return fullWidth < availableWidth ? "modelAndThinking" : "model";
}
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
@@ -85,6 +85,7 @@ export function AgentModeControl({
surface = "toolbar",
onClose,
}: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) {
const { theme } = useUnistyles();
const { presentation } = useComposerControlLayout();
const { t } = useTranslation();
const { isActiveComposer } = useComposerKeyboardScope();
@@ -101,6 +102,7 @@ export function AgentModeControl({
}, [modeOptions, selectedModeId]);
const Icon = getAgentModeIcon(provider, selectedMode?.id ?? "", providerDefinitions);
const iconColor = theme.colors.foregroundMuted;
const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : "";
const allOptions = useMemo<ComboboxOption[]>(
@@ -195,6 +197,7 @@ export function AgentModeControl({
<AgentControlTrigger
ref={anchorRef}
icon={Icon}
iconColor={iconColor}
surface={surface}
label={t("agentControls.mode.title")}
value={selectedModeLabel}
@@ -1,36 +1,21 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Keyboard,
ScrollView,
Text,
View,
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { TailEllipsisText } from "@/components/ui/tail-ellipsis-text";
import { useProviderIcon } from "@/components/provider-icons";
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
import { resolveModelBrowserScrolling } from "@/components/model-browser-view";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import { resolveModelTriggerLabels } from "@/composer/agent-controls/layout";
import { resolveModelSheetOpening } from "@/composer/agent-controls/model-sheet-flow";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import {
TOUCH_ICON_SIZE,
toolbarContentSlot,
toolbarSlot,
type ToolbarGlyphs,
} from "@/components/ui/control-geometry";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
const SNAP_POINTS = ["80%", "90%"];
const MODEL_LIST_TOP_INSET = 4;
@@ -57,7 +42,7 @@ interface CompactModelSheetProps {
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
glyphs: ToolbarGlyphs;
glyphSize: number;
/** False when the toolbar is too narrow for labels: the trigger shows the provider glyph only. */
showLabels: boolean;
canSwitchProvider: boolean;
@@ -93,43 +78,6 @@ function resolveModelSheetProfileActions(
};
}
/**
* Measures the folded model control and decides which labels it shows; see
* `resolveModelTriggerLabels`. The full width is keyed by what was measured, so a new label is
* `measuring` until its own width is known instead of reusing the previous label's.
*/
function useModelTriggerLabels(input: {
modelLabel: string;
thinkingLabel: string | null;
glyphSize: number;
}) {
const { modelLabel, thinkingLabel, glyphSize } = input;
const measuredKey = `${glyphSize}\n${modelLabel}\n${thinkingLabel ?? ""}`;
const [availableWidth, setAvailableWidth] = useState<number | undefined>(undefined);
const [fullWidth, setFullWidth] = useState<{ key: string; width: number } | null>(null);
const onFrameLayout = useCallback((event: LayoutChangeEvent) => {
const width = readMeasuredWidth(event);
if (width !== null) setAvailableWidth(width);
}, []);
const onFullLayout = useCallback(
(event: LayoutChangeEvent) => {
const width = readMeasuredWidth(event);
if (width !== null) setFullWidth({ key: measuredKey, width });
},
[measuredKey],
);
const labels = resolveModelTriggerLabels({
hasThinking: thinkingLabel !== null,
measurements: {
availableWidth,
fullWidth: fullWidth?.key === measuredKey ? fullWidth.width : undefined,
},
});
return { labels, measuredKey, onFrameLayout, onFullLayout };
}
export function CompactModelSheet({
providers,
selectedProvider,
@@ -148,7 +96,7 @@ export function CompactModelSheet({
isRetryingProvider = false,
disabled = false,
serverId = null,
glyphs,
glyphSize,
showLabels,
canSwitchProvider,
children,
@@ -285,6 +233,17 @@ export function CompactModelSheet({
open();
}, [close, isOpen, open]);
const iconOnly = !showLabels && hasSelectedProvider;
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.trigger,
iconOnly && styles.triggerIconOnly,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
],
[disabled, iconOnly, isOpen],
);
const mobileRootContent = useMemo(
() => (
<View style={styles.mobileRootContent} testID="agent-controls-settings-list">
@@ -310,19 +269,36 @@ export function CompactModelSheet({
return (
<>
<ModelSheetTrigger
ModelIcon={hasSelectedProvider ? ModelIcon : null}
modelLabel={shortModelLabel(rootBrowser.triggerLabel)}
thinkingLabel={thinkingLabel}
glyphs={glyphs}
iconOnly={!showLabels && hasSelectedProvider}
isOpen={isOpen}
<ComboboxTrigger
collapsable={false}
disabled={disabled}
onPress={toggle}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.selectedModel", {
model: rootBrowser.selectedModelLabel,
})}
/>
testID="combined-model-selector"
chevron={null}
>
{hasSelectedProvider ? (
<ComposerToolbarGlyph size={glyphSize}>
<ModelIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph>
) : null}
{!iconOnly ? (
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(rootBrowser.triggerLabel)}
</Text>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
</Text>
) : null}
</View>
) : null}
</ComboboxTrigger>
<AdaptiveModalSheet
header={rootHeader}
@@ -404,137 +380,20 @@ export function CompactModelSheet({
);
}
/**
* The folded model control in the toolbar: the provider glyph, then the model and thinking
* labels for as long as they fit.
*/
function ModelSheetTrigger({
ModelIcon,
modelLabel,
thinkingLabel,
glyphs,
iconOnly,
isOpen,
disabled,
onPress,
accessibilityLabel,
}: {
/** The selected provider's glyph, or null before a provider is selected. */
ModelIcon: ReturnType<typeof useProviderIcon> | null;
modelLabel: string;
thinkingLabel: string | null;
glyphs: ToolbarGlyphs;
iconOnly: boolean;
isOpen: boolean;
disabled: boolean;
onPress: () => void;
accessibilityLabel: string;
}) {
const glyphRole = iconOnly ? "icon" : "label";
const glyph = glyphs[glyphRole];
const triggerLabels = useModelTriggerLabels({
modelLabel,
thinkingLabel: iconOnly ? null : thinkingLabel,
glyphSize: glyph.size,
});
const providerIconColor =
glyph.tone === "foreground" ? styles.providerIconForeground.color : styles.providerIcon.color;
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.trigger,
iconOnly && styles.triggerIconOnly,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
],
[disabled, iconOnly, isOpen],
);
return (
<View style={styles.triggerFrame} onLayout={triggerLabels.onFrameLayout}>
<ComboboxTrigger
collapsable={false}
disabled={disabled}
onPress={onPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID="combined-model-selector"
chevron={null}
>
{ModelIcon ? (
<ComposerToolbarGlyph role={glyphRole}>
<ModelIcon
size={glyph.size}
strokeWidth={glyph.strokeWidth}
color={providerIconColor}
/>
</ComposerToolbarGlyph>
) : null}
{!iconOnly ? (
<View
style={[
styles.triggerLabels,
triggerLabels.labels === "measuring" && styles.triggerLabelsMeasuring,
]}
>
<TailEllipsisText
text={modelLabel}
style={styles.triggerText}
testID="combined-model-selector-model-label"
/>
{thinkingLabel && triggerLabels.labels !== "model" ? (
<Text
style={styles.triggerThinking}
numberOfLines={1}
testID="combined-model-selector-thinking-label"
>
{thinkingLabel}
</Text>
) : null}
</View>
) : null}
</ComboboxTrigger>
{!iconOnly && thinkingLabel ? (
<View
// Keyed by what it measures: a retained node whose size does not change emits no new
// layout event, so new labels would never be measured.
key={triggerLabels.measuredKey}
style={[styles.trigger, styles.triggerMeasurer]}
onLayout={triggerLabels.onFullLayout}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
>
{ModelIcon ? <ComposerToolbarGlyph role={glyphRole}>{null}</ComposerToolbarGlyph> : null}
<View style={styles.triggerLabels}>
<Text style={[styles.triggerText, styles.triggerMeasuredText]} numberOfLines={1}>
{modelLabel}
</Text>
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
</Text>
</View>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
backgroundColor: "transparent",
},
triggerIconOnly: {
...toolbarSlot(TOUCH_ICON_SIZE.md),
width: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -543,7 +402,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
triggerPressed: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
},
triggerDisabled: {
opacity: 0.5,
@@ -562,28 +421,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[1],
},
triggerLabelsMeasuring: {
opacity: 0,
},
/** The space the trigger may take; the trigger itself stays as wide as its content. */
triggerFrame: {
minWidth: 0,
flexGrow: 1,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
},
/** An invisible copy of the trigger with both labels untruncated. */
triggerMeasurer: {
position: "absolute",
left: 0,
top: 0,
flexShrink: 0,
opacity: 0,
},
triggerMeasuredText: {
flexShrink: 0,
},
triggerThinking: {
flexShrink: 0,
color: theme.colors.foregroundExtraMuted,
@@ -593,9 +430,6 @@ const styles = StyleSheet.create((theme) => ({
providerIcon: {
color: theme.colors.foregroundMuted,
},
providerIconForeground: {
color: theme.colors.foreground,
},
sheetBody: {
paddingHorizontal: 0,
paddingTop: 0,
+88 -101
View File
@@ -33,16 +33,10 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
controlSizingForLayout,
TOOLBAR_CONTROL_GAP,
TOOLBAR_DISC_GLYPH_SIZES,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_DISC_SIZE,
TOOLBAR_RINGS,
toolbarGlyphsFor,
toolbarGlyphTone,
type ToolbarGlyph,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { COMPOSER_TOOLBAR_SHAPES } from "@/composer/input/toolbar-rails";
import { usePanelStore } from "@/stores/panel-store";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
import { useShallow } from "zustand/shallow";
@@ -266,22 +260,20 @@ function resolveCheckoutRemoteUrl(
return checkoutStatus?.remoteUrl ?? null;
}
function buildCancelButtonStyle(
pressed: boolean,
isConnected: boolean,
isCancellingAgent: boolean,
): object[] {
const pressedStyle = pressed ? styles.discPressed : undefined;
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, pressedStyle, disabled].filter((value): value is object =>
Boolean(value),
);
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
}
function buildRealtimeVoiceButtonStyle(pressed: boolean, voiceButtonDisabled: boolean): object[] {
const pressedStyle = pressed ? styles.discPressed : undefined;
function buildRealtimeVoiceButtonStyle(
hovered: boolean | undefined,
voiceButtonDisabled: boolean,
reserveLeadingSpace: boolean,
): object[] {
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
return [styles.realtimeVoiceButton, pressedStyle, disabledStyle].filter(
const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined;
return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter(
(value): value is object => Boolean(value),
);
}
@@ -1090,8 +1082,8 @@ function ComposerForgeBinding({
}
interface ComposerCancelButtonProps {
discGlyphSize: number;
cancelButtonStyle: (state: PressableStateCallbackType) => object[];
buttonIconSize: number;
cancelButtonStyle: (object | undefined)[];
handleCancelAgent: () => void;
isConnected: boolean;
isCancellingAgent: boolean;
@@ -1100,7 +1092,7 @@ interface ComposerCancelButtonProps {
}
function ComposerCancelButton({
discGlyphSize,
buttonIconSize,
cancelButtonStyle,
handleCancelAgent,
isConnected,
@@ -1114,7 +1106,7 @@ function ComposerCancelButton({
const icon = isCancellingAgent ? (
<LoadingSpinner size="small" color="white" />
) : (
<Square size={discGlyphSize} color="white" fill="white" />
<Square size={buttonIconSize} color="white" fill="white" />
);
const shortcutNode = agentInterruptKeys ? <Shortcut chord={agentInterruptKeys} /> : null;
return (
@@ -1139,7 +1131,7 @@ function ComposerCancelButton({
}
interface ComposerVoiceModeButtonProps {
glyph: ToolbarGlyph;
buttonIconSize: number;
handleToggleRealtimeVoice: () => void;
isConnected: boolean;
isVoiceSwitching: boolean;
@@ -1150,25 +1142,37 @@ interface ComposerVoiceModeButtonProps {
t: TFunction;
}
function showsVoiceModeButton(input: {
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
isVoiceModeForAgent: boolean;
hasAgent: boolean;
isAgentRunning: boolean;
hasSendableContent: boolean;
isCompact: boolean;
showVoice: boolean;
}): boolean {
// Voice mode holds the trailing disc until there is something to send, then Send takes it.
}
function ComposerRightControlsSlot({
isVoiceModeForAgent,
hasAgent,
isAgentRunning,
hasSendableContent,
isCompact,
showVoice,
...voiceProps
}: ComposerRightControlsSlotProps) {
const hideVoiceForCompactInput = isCompact && hasSendableContent;
const showVoiceModeButton =
showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
if (!showVoiceModeButton) return null;
return (
input.showVoice &&
!input.isVoiceModeForAgent &&
input.hasAgent &&
!input.isAgentRunning &&
!input.hasSendableContent
<View style={styles.rightControls}>
<ComposerVoiceModeButton {...voiceProps} />
</View>
);
}
function ComposerVoiceModeButton({
glyph,
buttonIconSize,
handleToggleRealtimeVoice,
isConnected,
isVoiceSwitching,
@@ -1179,25 +1183,13 @@ function ComposerVoiceModeButton({
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
const renderTriggerContent = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
const colorMapping =
toolbarGlyphTone(glyph, Boolean(hovered)) === "foreground"
? iconForegroundMapping
: iconForegroundMutedMapping;
return (
<View style={styles.voiceModeDisc}>
{isVoiceSwitching ? (
<LoadingSpinner size="small" color="white" />
) : (
<ThemedAudioLines
size={glyph.size}
strokeWidth={glyph.strokeWidth}
uniProps={colorMapping}
/>
)}
</View>
);
if (isVoiceSwitching) {
return <LoadingSpinner size="small" color="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
},
[glyph, isVoiceSwitching],
[buttonIconSize, isVoiceSwitching],
);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -1319,9 +1311,7 @@ function ComposerContentImpl({
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const controlSizing = controlSizingForLayout(isCompactFormFactor);
const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon;
const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing];
const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)];
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const { finePointer: hasFinePointer } = usePointerCapabilities();
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
@@ -2022,9 +2012,8 @@ function ComposerContentImpl({
[agentId, recall, replaceUserInput, serverId],
);
const cancelButtonStyle = useCallback(
(state: PressableStateCallbackType) =>
buildCancelButtonStyle(state.pressed, isConnected, isCancellingAgent),
const cancelButtonStyle = useMemo(
() => buildCancelButtonStyle(isConnected, isCancellingAgent),
[isConnected, isCancellingAgent],
);
@@ -2032,14 +2021,14 @@ function ComposerContentImpl({
const voiceButtonDisabled = !isConnected || isVoiceSwitching;
const realtimeVoiceButtonStyle = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) =>
buildRealtimeVoiceButtonStyle(state.pressed, voiceButtonDisabled),
[voiceButtonDisabled],
buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout),
[isCompactLayout, voiceButtonDisabled],
);
const activeActionContent = useMemo(
() => (
<ComposerCancelButton
discGlyphSize={discGlyphSize}
buttonIconSize={buttonIconSize}
cancelButtonStyle={cancelButtonStyle}
handleCancelAgent={handleCancelAgent}
isConnected={isConnected}
@@ -2050,7 +2039,7 @@ function ComposerContentImpl({
),
[
agentInterruptKeys,
discGlyphSize,
buttonIconSize,
cancelButtonStyle,
handleCancelAgent,
isCancellingAgent,
@@ -2059,34 +2048,32 @@ function ComposerContentImpl({
],
);
const voiceModeContent = useMemo(
() =>
showsVoiceModeButton({
isVoiceModeForAgent,
hasAgent,
isAgentRunning,
hasSendableContent,
showVoice: mode.showVoice,
}) ? (
<View style={styles.rightControls}>
<ComposerVoiceModeButton
glyph={toolbarGlyph}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
voiceToggleKeys={voiceToggleKeys}
t={t}
/>
</View>
) : null,
const rightContent = useMemo(
() => (
<ComposerRightControlsSlot
isVoiceModeForAgent={isVoiceModeForAgent}
hasAgent={hasAgent}
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isCompact={isCompactLayout}
showVoice={mode.showVoice}
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
voiceToggleKeys={voiceToggleKeys}
t={t}
/>
),
[
toolbarGlyph,
buttonIconSize,
handleToggleRealtimeVoice,
hasAgent,
hasSendableContent,
isAgentRunning,
isConnected,
isCompactLayout,
isVoiceModeForAgent,
isVoiceSwitching,
mode.showVoice,
@@ -2106,7 +2093,7 @@ function ComposerContentImpl({
maxTokens={agentState.contextWindowMaxTokens}
usedTokens={agentState.contextWindowUsedTokens}
totalCostUsd={agentState.totalCostUsd}
toolbarRing={TOOLBAR_RINGS[controlSizing]}
glyphSize={buttonIconSize}
/>
</View>
) : null,
@@ -2117,7 +2104,7 @@ function ComposerContentImpl({
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
agentState.totalCostUsd,
controlSizing,
buttonIconSize,
],
);
@@ -2503,7 +2490,7 @@ function ComposerContentImpl({
disabled={isSubmitLoading}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
voiceModeContent={voiceModeContent}
rightContent={rightContent}
activeActionContent={activeActionContent}
voiceServerId={serverId}
voiceAgentId={agentId}
@@ -2609,10 +2596,13 @@ 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: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc),
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
cancelButtonDisc: {
width: TOOLBAR_DISC_SIZE,
@@ -2625,34 +2615,31 @@ const styles = StyleSheet.create((theme: Theme) => ({
rightControls: {
flexDirection: "row",
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
gap: theme.spacing[1],
},
contextWindowMeterSlot: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.ring),
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
voiceModeDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
realtimeVoiceButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc),
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
realtimeVoiceButtonCompactReserve: {
marginLeft: theme.spacing[1],
},
realtimeVoiceButtonActive: {
backgroundColor: theme.colors.palette.green[600],
borderColor: theme.colors.palette.green[800],
},
discPressed: {
opacity: 0.7,
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
attachmentTray: {
flexDirection: "row",
+77 -138
View File
@@ -31,24 +31,10 @@ import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import {
controlSizingForLayout,
TOOLBAR_CONTROL_GAP,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_DISC_GLYPH_SIZES,
TOOLBAR_DISC_SIZE,
toolbarGlyphsFor,
toolbarGlyphTone,
type ToolbarGlyph,
toolbarSlot,
} from "@/components/ui/control-geometry";
import {
COMPOSER_PADDING_BOTTOM,
COMPOSER_PADDING_HORIZONTAL,
COMPOSER_CORNER_RADIUS,
COMPOSER_PADDING_TOP,
COMPOSER_TOOLBAR_SHAPES,
composerToolbarOverhang,
resolveComposerLeadingEdge,
} from "@/composer/input/toolbar-rails";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import { useDictation } from "@/hooks/use-dictation";
@@ -184,8 +170,8 @@ export interface MessageInputProps {
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
beforeVoiceContent?: React.ReactNode;
/** The realtime voice mode button after the dictation button, or null when the row hides it. */
voiceModeContent?: React.ReactNode;
/** Auxiliary content to render on the right side after the voice button. */
rightContent?: React.ReactNode;
/** Primary action to render when the agent is active and the composer has no sendable content. */
activeActionContent?: React.ReactNode;
voiceServerId?: string;
@@ -266,28 +252,19 @@ interface TextAreaHandle {
};
}
function glyphColorMapping(glyph: ToolbarGlyph, hovered: boolean) {
return toolbarGlyphTone(glyph, hovered) === "foreground"
? iconForegroundMapping
: iconForegroundMutedMapping;
}
function AttachButtonIcon({
hovered,
onAttachButtonRef,
glyph,
buttonIconSize,
}: {
hovered: boolean;
onAttachButtonRef: ((node: View | null) => void) | undefined;
glyph: ToolbarGlyph;
buttonIconSize: number;
}) {
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return (
<View ref={onAttachButtonRef} collapsable={false} style={styles.attachButtonAnchor}>
<ThemedPlus
size={glyph.size}
strokeWidth={glyph.strokeWidth}
uniProps={glyphColorMapping(glyph, hovered)}
/>
<ThemedPlus size={buttonIconSize} uniProps={colorMapping} />
</View>
);
}
@@ -365,25 +342,25 @@ function VoiceButtonIcon({
hovered,
isDictating,
isMutedRealtime,
glyph,
discGlyphSize,
buttonIconSize,
}: {
hovered: boolean;
isDictating: boolean;
isMutedRealtime: boolean;
glyph: ToolbarGlyph;
discGlyphSize: number;
buttonIconSize: number;
}) {
if (isDictating) {
return (
<View style={styles.voiceButtonRecordingDisc}>
<Square size={discGlyphSize} color="white" fill="white" />
<Square size={buttonIconSize} color="white" fill="white" />
</View>
);
}
const colorMapping = glyphColorMapping(glyph, hovered);
const Icon = isMutedRealtime ? ThemedMicOff : ThemedMic;
return <Icon size={glyph.size} strokeWidth={glyph.strokeWidth} uniProps={colorMapping} />;
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
if (isMutedRealtime) {
return <ThemedMicOff size={buttonIconSize} uniProps={colorMapping} />;
}
return <ThemedMic size={buttonIconSize} uniProps={colorMapping} />;
}
type ShortcutChord = NonNullable<React.ComponentProps<typeof Shortcut>["chord"]>;
@@ -422,12 +399,12 @@ function SendButtonContent({
isSubmitLoading,
submitIcon,
submitLabel,
glyph,
buttonIconSize,
}: {
isSubmitLoading: boolean;
submitIcon: "arrow" | "return";
submitLabel: string | undefined;
glyph: ToolbarGlyph;
buttonIconSize: number;
}) {
if (isSubmitLoading) {
return <ThemedLoadingSpinner size="small" uniProps={iconAccentForegroundMapping} />;
@@ -436,21 +413,9 @@ function SendButtonContent({
return <Text style={styles.sendButtonLabel}>{submitLabel}</Text>;
}
if (submitIcon === "return") {
return (
<ThemedCornerDownLeft
size={glyph.size}
strokeWidth={glyph.strokeWidth}
uniProps={iconAccentForegroundMapping}
/>
);
return <ThemedCornerDownLeft size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
}
return (
<ThemedArrowUp
size={glyph.size}
strokeWidth={glyph.strokeWidth}
uniProps={iconAccentForegroundMapping}
/>
);
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
}
interface DesktopKeyPressContext {
@@ -843,7 +808,7 @@ function SendButtonTooltip({
submitIcon,
submitLabel,
submitButtonTestID,
glyph,
buttonIconSize,
sendKeys,
sendTooltipLabel,
}: {
@@ -860,7 +825,7 @@ function SendButtonTooltip({
submitIcon: "arrow" | "return";
submitLabel: string | undefined;
submitButtonTestID: string | undefined;
glyph: ToolbarGlyph;
buttonIconSize: number;
sendKeys: ShortcutChord | null | undefined;
sendTooltipLabel: string;
}) {
@@ -882,7 +847,7 @@ function SendButtonTooltip({
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
glyph={glyph}
buttonIconSize={buttonIconSize}
/>
</View>
</TooltipTrigger>
@@ -1175,7 +1140,7 @@ interface ResolvedMessageInputProps {
disabled: boolean;
leftContent: React.ReactNode;
beforeVoiceContent: React.ReactNode;
voiceModeContent: React.ReactNode;
rightContent: React.ReactNode;
activeActionContent: React.ReactNode;
voiceServerId: string | undefined;
voiceAgentId: string | undefined;
@@ -1223,7 +1188,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
disabled: props.disabled ?? false,
leftContent: props.leftContent,
beforeVoiceContent: props.beforeVoiceContent,
voiceModeContent: props.voiceModeContent,
rightContent: props.rightContent,
activeActionContent: props.activeActionContent,
voiceServerId: props.voiceServerId,
voiceAgentId: props.voiceAgentId,
@@ -1251,12 +1216,8 @@ function extractErrorMessage(error: unknown): string | null {
return null;
}
/**
* Send that stands in for the trailing disc, in fullscreen or while there is nothing to send, is
* disabled until there is content.
*/
function resolvePresentationSendDisabled(input: {
standsIn: boolean;
fullscreen: boolean;
disabled: boolean;
hasText: boolean;
attachments: ComposerAttachment[];
@@ -1264,7 +1225,7 @@ function resolvePresentationSendDisabled(input: {
allowEmpty: boolean;
}): boolean {
return (
input.disabled || (input.standsIn && !input.allowEmpty && !hasSendableComposerContent(input))
input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input))
);
}
@@ -1305,7 +1266,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
disabled,
leftContent,
beforeVoiceContent,
voiceModeContent,
rightContent,
activeActionContent,
voiceServerId,
voiceAgentId,
@@ -1335,8 +1296,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const [renderedLines, setRenderedLines] = useState(1);
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
const controlSizing = controlSizingForLayout(isCompact);
const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon;
const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing];
const buttonIconSize = iconSizeFor("md")[controlSizing];
const toast = useToast();
const voice = useVoiceOptional();
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
@@ -1959,20 +1919,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
);
const attachButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
({ hovered }: { hovered?: boolean }) => [
styles.attachButton,
Boolean(hovered || pressed) && styles.iconButtonHovered,
Boolean(hovered) && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
],
[isConnected, disabled],
);
const voiceButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
({ hovered }: { hovered?: boolean }) => [
styles.voiceButton,
isDictating && styles.voiceButtonRecording,
Boolean(hovered || pressed) && !isDictating && styles.iconButtonHovered,
Boolean(pressed) && isDictating && styles.discPressed,
Boolean(hovered) && !isDictating && styles.iconButtonHovered,
!isDictationStartEnabled && styles.buttonDisabled,
],
[isDictating, isDictationStartEnabled],
@@ -2013,24 +1971,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[mode.isMonospace],
);
// Fullscreen always shows Send; without content it is disabled, and looks it.
// With nothing to send and no voice mode, a disabled Send holds the trailing disc so the row
// does not shift when typing starts.
const sendStandsIn = isFullscreen || (primaryActionKind === "none" && voiceModeContent == null);
const presentedActionKind: PrimaryActionKind = sendStandsIn ? "send" : primaryActionKind;
const isPresentedSendDisabled = resolvePresentationSendDisabled({
standsIn: sendStandsIn,
fullscreen: isFullscreen,
disabled: isSendButtonDisabled,
hasText: hasLiveText,
attachments,
hasExternalContent,
allowEmpty: allowEmptySubmit,
});
const sendButtonCombinedStyle = useCallback(
({ pressed }: { pressed?: boolean }) => [
styles.sendButton,
Boolean(pressed) && styles.discPressed,
isPresentedSendDisabled && styles.buttonDisabled,
],
const sendButtonCombinedStyle = useMemo(
() => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled],
[isPresentedSendDisabled],
);
const overlayContainerStyle = useMemo(
@@ -2043,10 +1993,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<AttachButtonIcon
hovered={Boolean(hovered)}
onAttachButtonRef={onAttachButtonRef}
glyph={toolbarGlyph}
buttonIconSize={buttonIconSize}
/>
),
[onAttachButtonRef, toolbarGlyph],
[onAttachButtonRef, buttonIconSize],
);
const renderVoiceButtonIcon = useCallback(
@@ -2055,17 +2005,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
hovered={Boolean(hovered)}
isDictating={isDictating}
isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)}
glyph={toolbarGlyph}
discGlyphSize={discGlyphSize}
buttonIconSize={buttonIconSize}
/>
),
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, toolbarGlyph, discGlyphSize],
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
);
const sendButton = useMemo(
() => (
<PrimaryAction
kind={presentedActionKind}
kind={isFullscreen ? "send" : primaryActionKind}
activeActionContent={activeActionContent}
shouldShow
canPressLoadingButton={canPressLoadingButton}
@@ -2080,13 +2029,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitIcon={submitIcon}
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
glyph={toolbarGlyph}
buttonIconSize={buttonIconSize}
sendKeys={resolveSendKeys(sendShortcut)}
sendTooltipLabel={sendTooltipLabel}
/>
),
[
presentedActionKind,
isFullscreen,
primaryActionKind,
activeActionContent,
canPressLoadingButton,
onSubmitLoadingPress,
@@ -2100,7 +2050,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitIcon,
submitLabel,
submitButtonTestID,
toolbarGlyph,
buttonIconSize,
sendShortcut,
sendTooltipLabel,
],
@@ -2174,25 +2124,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
focusInputKeys,
],
);
const leadingEdge = resolveComposerLeadingEdge({
showsAttach: mode.showAttachments,
showsAgentControls: leftContent != null,
});
// The trailing control is always a disc: voice mode, then Send or Stop.
const trailingEdge = "disc";
const buttonRowStyle = useMemo(
() => [
styles.buttonRow,
{
marginLeft: -composerToolbarOverhang(leadingEdge, controlSizing),
marginRight: -composerToolbarOverhang(trailingEdge, controlSizing),
},
],
[controlSizing, leadingEdge, trailingEdge],
);
const toolbar = useMemo(
() => (
<View style={buttonRowStyle}>
<View style={styles.buttonRow}>
{/* Toolbar left: attachment button + agent controls */}
<View style={styles.leftButtonGroup}>
<AttachmentDropdown
@@ -2222,13 +2156,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceMuteToggleKeys={voiceMuteToggleKeys}
dictationToggleKeys={dictationToggleKeys}
/>
{voiceModeContent}
{rightContent}
{sendButton}
</View>
</View>
),
[
buttonRowStyle,
mode.showAttachments,
isConnected,
disabled,
@@ -2248,7 +2181,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isRealtimeVoiceForCurrentAgent,
voiceMuteToggleKeys,
dictationToggleKeys,
voiceModeContent,
rightContent,
sendButton,
],
);
@@ -2316,7 +2249,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<ComposerInputPresentation
fullscreen={isFullscreen}
controlSizing={controlSizing}
sendIconSize={toolbarGlyph.size}
sendIconSize={buttonIconSize}
surface={textSurface}
toolbar={toolbar}
send={sendButton}
@@ -2357,10 +2290,20 @@ const styles = StyleSheet.create((theme: Theme) => ({
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: COMPOSER_CORNER_RADIUS,
paddingTop: COMPOSER_PADDING_TOP,
paddingBottom: COMPOSER_PADDING_BOTTOM,
paddingHorizontal: COMPOSER_PADDING_HORIZONTAL,
borderRadius: theme.borderRadius["2xl"],
paddingTop: {
xs: theme.spacing[2],
md: theme.spacing[4],
},
// The button row bleeds 6px horizontally, so match its corner inset at the bottom.
paddingBottom: {
xs: theme.spacing[2],
md: theme.spacing[3],
},
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[4],
},
...(isWeb
? {
transitionProperty: "border-color",
@@ -2434,49 +2377,47 @@ const styles = StyleSheet.create((theme: Theme) => ({
minHeight: MIN_INPUT_HEIGHT,
color: theme.colors.foregroundMuted,
},
// The row keeps a full touch target's height; its disc-sized controls centre in it.
buttonRow: {
minHeight: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
alignItems: "flex-end",
justifyContent: "space-between",
marginHorizontal: -6,
},
leftButtonGroup: {
minWidth: 0,
flexShrink: 1,
flexGrow: 1,
flexDirection: "row",
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
alignItems: "flex-end",
gap: theme.spacing[0],
},
rightButtonGroup: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
gap: theme.spacing[1],
},
attachButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.attach),
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
attachButtonAnchor: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.dictation),
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecording: {
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
},
voiceButtonRecordingDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
@@ -2485,12 +2426,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
sendButton: {
minWidth: TOOLBAR_DISC_SIZE,
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
height: TOOLBAR_DISC_SIZE,
minWidth: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
sendButtonDisc: {
width: TOOLBAR_DISC_SIZE,
@@ -2517,9 +2459,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
discPressed: {
opacity: 0.7,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { composerToolbarOverhang, resolveComposerLeadingEdge } from "./toolbar-rails";
describe("composer toolbar rails", () => {
it("keeps touch glyphs and labels on the text rails and nests a trailing disc into the corner", () => {
for (const edge of ["attach", "dictation", "voiceMode", "label", "none"] as const) {
expect(composerToolbarOverhang(edge, "touch")).toBe(0);
}
// A 36pt disc centred in the 44pt row sits 8 from the bottom edge, so it sits 8 from the side.
expect(composerToolbarOverhang("disc", "touch")).toBe(8);
});
it("keeps the pointer row's flat overhang", () => {
for (const edge of ["attach", "dictation", "voiceMode", "disc", "label", "none"] as const) {
expect(composerToolbarOverhang(edge, "pointer")).toBe(6);
}
});
it("starts the row on attach, else on the agent controls", () => {
expect(resolveComposerLeadingEdge({ showsAttach: true, showsAgentControls: true })).toBe(
"attach",
);
expect(resolveComposerLeadingEdge({ showsAttach: false, showsAgentControls: true })).toBe(
"label",
);
expect(resolveComposerLeadingEdge({ showsAttach: false, showsAgentControls: false })).toBe(
"none",
);
});
});
@@ -1,85 +0,0 @@
import {
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_SIZES,
TOOLBAR_RINGS,
toolbarGlyphsFor,
type ControlSizing,
} from "@/components/ui/control-geometry";
import { bySizing } from "@/components/ui/control-sizing";
/** The control at one end of the composer toolbar, which decides how far the row hangs out. */
export type ComposerToolbarEdge = "attach" | "label" | "dictation" | "voiceMode" | "disc" | "none";
/**
* The composer's padding. Its sides are the rails: the text column and the toolbar's outer
* controls both start there.
*/
const COMPOSER_PADDING = {
touch: { top: 12, horizontal: 16, bottom: 4 },
pointer: { top: 16, horizontal: 16, bottom: 12 },
} as const;
export const COMPOSER_PADDING_TOP = bySizing({
touch: COMPOSER_PADDING.touch.top,
pointer: COMPOSER_PADDING.pointer.top,
});
export const COMPOSER_PADDING_HORIZONTAL = bySizing({
touch: COMPOSER_PADDING.touch.horizontal,
pointer: COMPOSER_PADDING.pointer.horizontal,
});
export const COMPOSER_PADDING_BOTTOM = bySizing({
touch: COMPOSER_PADDING.touch.bottom,
pointer: COMPOSER_PADDING.pointer.bottom,
});
/** How much of Lucide's 24-unit grid each toolbar glyph's ink spans, centred in its box. */
const GLYPH_INK_SPANS = { plus: 14, mic: 14, audioLines: 20 } as const;
function touchGlyphInkWidth(span: number): number {
const glyph = toolbarGlyphsFor("touch").icon;
return (span * glyph.size) / 24 + glyph.strokeWidth;
}
/** The width each toolbar control paints when touch-sized; the row spaces these. */
export const COMPOSER_TOOLBAR_SHAPES = {
attach: touchGlyphInkWidth(GLYPH_INK_SPANS.plus),
dictation: touchGlyphInkWidth(GLYPH_INK_SPANS.mic),
voiceMode: touchGlyphInkWidth(GLYPH_INK_SPANS.audioLines),
ring: TOOLBAR_RINGS.touch.size,
disc: TOOLBAR_DISC_SIZES.touch,
} as const;
/** Pointer-sized the row hangs a flat 6px into the padding, which puts each 16pt glyph box on the rail. */
const POINTER_OVERHANG = 6;
/** How far a touch-sized disc sits from the composer's bottom edge, centred in the toolbar row. */
const DISC_EDGE_INSET =
COMPOSER_PADDING.touch.bottom + (TOOLBAR_CONTROL_SIZES.touch - TOOLBAR_DISC_SIZES.touch) / 2;
/**
* A trailing disc nests into the composer's corner: it reaches past the rail until it sits as far
* from the right edge as from the bottom edge.
*/
const DISC_CORNER_NEST = COMPOSER_PADDING.touch.horizontal - DISC_EDGE_INSET;
/** Touch-sized the composer's corner is concentric with the disc nested in it. */
export const COMPOSER_CORNER_RADIUS = bySizing({
touch: TOOLBAR_DISC_SIZES.touch / 2 + DISC_EDGE_INSET,
pointer: 16,
});
/** How far the composer toolbar hangs past the text column at one end. */
export function composerToolbarOverhang(edge: ComposerToolbarEdge, sizing: ControlSizing): number {
if (sizing === "pointer") return POINTER_OVERHANG;
return edge === "disc" ? DISC_CORNER_NEST : 0;
}
/** The control that starts the toolbar on the left. */
export function resolveComposerLeadingEdge(input: {
showsAttach: boolean;
showsAgentControls: boolean;
}): ComposerToolbarEdge {
if (input.showsAttach) return "attach";
if (input.showsAgentControls) return "label";
return "none";
}
@@ -4,10 +4,7 @@ import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
markWorkspaceArchivePending,
markWorkspaceArchiveUndoPending,
shouldSuppressWorkspaceForLocalArchive,
settleWorkspaceArchiveIntent,
shouldSuppressWorkspaceRemovalForLocalUndo,
} from "@/contexts/session-workspace-upserts";
const baseWorkspace: WorkspaceDescriptor = {
@@ -119,41 +116,3 @@ describe("workspace archive pending suppression", () => {
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
});
});
describe("workspace archive undo intent", () => {
const target = { serverId: "server-1", workspaceId: "/repo/worktree" };
it("lets host upserts through and holds host removes while an undo is pending", () => {
markWorkspaceArchivePending(target);
const undo = markWorkspaceArchiveUndoPending(target);
expect({
upsertSuppressed: shouldSuppressWorkspaceForLocalArchive({
serverId: target.serverId,
workspace: workspace({ archivingAt: null }),
}),
removeSuppressed: shouldSuppressWorkspaceRemovalForLocalUndo(target),
}).toEqual({ upsertSuppressed: false, removeSuppressed: true });
settleWorkspaceArchiveIntent(undo);
expect(shouldSuppressWorkspaceRemovalForLocalUndo(target)).toBe(false);
});
it("keeps a newer intent when the request behind an older one settles late", () => {
const firstArchive = markWorkspaceArchivePending(target);
const firstUndo = markWorkspaceArchiveUndoPending(target);
settleWorkspaceArchiveIntent(firstArchive);
clearWorkspaceArchivePending(target);
expect(shouldSuppressWorkspaceRemovalForLocalUndo(target)).toBe(true);
markWorkspaceArchivePending(target);
settleWorkspaceArchiveIntent(firstUndo);
expect(isWorkspaceArchivePending(target)).toBe(true);
const secondUndo = markWorkspaceArchiveUndoPending(target);
settleWorkspaceArchiveIntent(firstUndo);
expect(shouldSuppressWorkspaceRemovalForLocalUndo(target)).toBe(true);
settleWorkspaceArchiveIntent(secondUndo);
});
});
@@ -1,142 +1,77 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
/**
* What this client last asked the host to do with a workspace, while the host catches up.
*
* - `archive`: the row is hidden; host upserts for it are dropped until its remove arrives.
* - `undo`: the row is back; host removes for it are dropped until the undo response arrives.
*
* Each request gets its own intent. A request settles or checks only the intent it set, so a
* late response for an earlier request never touches the intent of a newer one.
*/
export interface WorkspaceArchiveIntent {
readonly kind: "archive" | "undo";
interface PendingWorkspaceArchive {
workspaceId: string;
}
const intentsByServer = new Map<string, Map<string, WorkspaceArchiveIntent>>();
const intentListeners = new Set<() => void>();
const targetByIntent = new WeakMap<
WorkspaceArchiveIntent,
{ serverId: string; workspaceId: string }
>();
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
interface WorkspaceIntentTarget {
serverId: string;
workspaceId?: string | null;
}
function resolveTarget(
input: WorkspaceIntentTarget,
): { serverId: string; workspaceId: string } | null {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) return null;
return { serverId, workspaceId };
}
function readIntent(input: WorkspaceIntentTarget): WorkspaceArchiveIntent | null {
const target = resolveTarget(input);
if (!target) return null;
return intentsByServer.get(target.serverId)?.get(target.workspaceId) ?? null;
}
function setIntent(
input: WorkspaceIntentTarget,
kind: WorkspaceArchiveIntent["kind"],
): WorkspaceArchiveIntent {
const intent: WorkspaceArchiveIntent = { kind };
const target = resolveTarget(input);
if (!target) return intent;
targetByIntent.set(intent, target);
const intents = intentsByServer.get(target.serverId) ?? new Map();
intents.set(target.workspaceId, intent);
intentsByServer.set(target.serverId, intents);
notifyIntentListeners();
return intent;
}
function deleteIntent(target: { serverId: string; workspaceId: string }): void {
const intents = intentsByServer.get(target.serverId);
if (!intents) return;
intents.delete(target.workspaceId);
if (intents.size === 0) intentsByServer.delete(target.serverId);
notifyIntentListeners();
}
function notifyIntentListeners(): void {
for (const listener of intentListeners) listener();
}
/** Calls `listener` whenever any workspace's archive intent changes. */
export function subscribeWorkspaceArchiveIntents(listener: () => void): () => void {
intentListeners.add(listener);
return () => {
intentListeners.delete(listener);
};
}
function clearIntentOfKind(
input: WorkspaceIntentTarget,
kind: WorkspaceArchiveIntent["kind"],
): void {
const target = resolveTarget(input);
if (!target || readIntent(target)?.kind !== kind) return;
deleteIntent(target);
}
/** True while `intent` is still the latest thing this client asked for its workspace. */
export function isWorkspaceArchiveIntentCurrent(intent: WorkspaceArchiveIntent): boolean {
const target = targetByIntent.get(intent);
return target !== undefined && readIntent(target) === intent;
}
/** The request behind `intent` settled. A newer request's intent stays in place. */
export function settleWorkspaceArchiveIntent(intent: WorkspaceArchiveIntent): void {
const target = targetByIntent.get(intent);
if (target && readIntent(target) === intent) deleteIntent(target);
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
}
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
}): WorkspaceArchiveIntent {
return setIntent(input, "archive");
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
/** The host removed the workspace, which settles whichever archive this client has pending. */
export function clearWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
}): void {
clearIntentOfKind(input, "archive");
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
if (!archives) {
return;
}
archives.delete(pendingArchiveKey({ serverId, workspaceId }));
if (archives.size === 0) {
pendingWorkspaceArchivesByServer.delete(serverId);
}
}
export function isWorkspaceArchivePending(input: WorkspaceIntentTarget): boolean {
return readIntent(input)?.kind === "archive";
}
export function markWorkspaceArchiveUndoPending(input: {
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
}): WorkspaceArchiveIntent {
return setIntent(input, "undo");
}
workspaceId?: string | null;
}): boolean {
const serverId = input.serverId.trim();
if (!serverId) {
return false;
}
export function isWorkspaceArchiveUndoPending(input: WorkspaceIntentTarget): boolean {
return readIntent(input)?.kind === "undo";
const archives = pendingWorkspaceArchivesByServer.get(serverId);
if (!archives) {
return false;
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId })));
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
serverId: string;
workspace: WorkspaceDescriptor;
}): boolean {
return isWorkspaceArchivePending({ serverId: input.serverId, workspaceId: input.workspace.id });
}
export function shouldSuppressWorkspaceRemovalForLocalUndo(input: {
serverId: string;
workspaceId: string;
}): boolean {
return isWorkspaceArchiveUndoPending(input);
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
});
}
-3
View File
@@ -1341,9 +1341,6 @@ export const ar: TranslationResources = {
hostDisconnected: "Host غير متصل",
hideFailed: "فشل في إخفاء مساحة العمل",
archiveFailed: "فشل في أرشفة مساحة العمل",
archived: "تمت أرشفة مساحة العمل",
undoArchive: "تراجع",
undoArchiveFailed: "تعذّر التراجع عن الأرشفة",
},
},
},
-3
View File
@@ -1349,9 +1349,6 @@ export const en = {
hostDisconnected: "Host is not connected",
hideFailed: "Failed to hide workspace",
archiveFailed: "Failed to archive workspace",
archived: "Workspace archived",
undoArchive: "Undo",
undoArchiveFailed: "Couldn't undo the archive",
},
},
},
-3
View File
@@ -1377,9 +1377,6 @@ export const es: TranslationResources = {
hostDisconnected: "Hostno está conectado",
hideFailed: "No se pudo ocultar el espacio de trabajo",
archiveFailed: "No se pudo archivar el espacio de trabajo",
archived: "Espacio de trabajo archivado",
undoArchive: "Deshacer",
undoArchiveFailed: "No se pudo deshacer el archivado",
},
},
},
-3
View File
@@ -1371,9 +1371,6 @@ export const fr: TranslationResources = {
hostDisconnected: "L’hôte n’est pas connecté",
hideFailed: "Impossible de masquer l’espace de travail",
archiveFailed: "Impossible d’archiver l’espace de travail",
archived: "Espace de travail archivé",
undoArchive: "Annuler",
undoArchiveFailed: "Impossible d’annuler l’archivage",
},
},
},
-3
View File
@@ -1355,9 +1355,6 @@ export const ja: TranslationResources = {
hostDisconnected: "ホストが接続されていません",
hideFailed: "ワークスペースの非表示に失敗しました",
archiveFailed: "ワークスペースのアーカイブに失敗しました",
archived: "ワークスペースをアーカイブしました",
undoArchive: "元に戻す",
undoArchiveFailed: "アーカイブを元に戻せませんでした",
},
},
},
-3
View File
@@ -1348,9 +1348,6 @@ export const ko: TranslationResources = {
hostDisconnected: "호스트가 연결되어 있지 않습니다",
hideFailed: "워크스페이스를 숨기지 못했습니다",
archiveFailed: "워크스페이스를 보관하지 못했습니다.",
archived: "워크스페이스를 보관했습니다",
undoArchive: "실행 취소",
undoArchiveFailed: "보관을 취소하지 못했습니다",
},
},
},
-3
View File
@@ -1367,9 +1367,6 @@ export const ptBR: TranslationResources = {
hostDisconnected: "Host não está conectado",
hideFailed: "Falha ao ocultar workspace",
archiveFailed: "Falha ao arquivar workspace",
archived: "Workspace arquivado",
undoArchive: "Desfazer",
undoArchiveFailed: "Não foi possível desfazer o arquivamento",
},
},
},
-3
View File
@@ -1359,9 +1359,6 @@ export const ru: TranslationResources = {
hostDisconnected: "Хост не подключён",
hideFailed: "Не удалось скрыть рабочее пространство",
archiveFailed: "Не удалось архивировать рабочее пространство",
archived: "Рабочее пространство архивировано",
undoArchive: "Отменить",
undoArchiveFailed: "Не удалось отменить архивацию",
},
},
},
-3
View File
@@ -1330,9 +1330,6 @@ export const zhCN: TranslationResources = {
hostDisconnected: "Host 未连接",
hideFailed: "隐藏 workspace 失败",
archiveFailed: "归档工作区失败",
archived: "工作区已归档",
undoArchive: "撤销",
undoArchiveFailed: "无法撤销归档",
},
},
},
-1
View File
@@ -52,7 +52,6 @@ export type KeyboardActionId =
| "workspace.project.pick"
| "worktree.new"
| "workspace.archive"
| "workspace.archive.undo"
| "workspace.pin"
| "view.toggle.focus"
| "theme.cycle"
@@ -51,7 +51,6 @@ export type KeyboardActionId =
| "workspace.project.pick"
| "worktree.new"
| "workspace.archive"
| "workspace.archive.undo"
| "workspace.pin"
// Command-center only: no keybind, so these are absent from route-shortcut.ts.
| "workspace.rename"
@@ -110,7 +109,6 @@ export type KeyboardActionDefinition =
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "workspace.archive"; scope: KeyboardActionScope }
| { id: "workspace.archive.undo"; scope: KeyboardActionScope }
| { id: "workspace.pin"; scope: KeyboardActionScope }
| { id: "workspace.rename"; scope: KeyboardActionScope }
| { id: "workspace.setup.show"; scope: KeyboardActionScope };
@@ -408,25 +408,6 @@ describe("keyboard-shortcuts", () => {
},
);
describe("undo archive workspace", () => {
it.each([
{ isMac: true, event: { key: "z", code: "KeyZ", metaKey: true } },
{ isMac: false, event: { key: "z", code: "KeyZ", ctrlKey: true } },
])("routes Cmd/Ctrl+Z outside text and terminals (mac: $isMac)", ({ isMac, event }) => {
expectShortcutResolution({ event, context: { isMac }, action: "workspace.archive.undo" });
});
it.each(["editable", "message-input", "terminal"] as const)(
"leaves Cmd+Z to the focused %s",
(focusScope) => {
expectNoShortcutResolution({
event: { key: "z", code: "KeyZ", metaKey: true },
context: { isMac: true, focusScope },
});
},
);
});
it("leaves Escape to an editable field", () => {
expectNoShortcutResolution({
event: { key: "Escape", code: "Escape" },
@@ -169,7 +169,6 @@ export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[
"workspace-next",
"pin-workspace",
"archive-workspace",
"undo-archive-workspace",
],
"tabs-panes": [
"workspace-tab-new",
@@ -362,31 +361,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Undo archive workspace ---
// Only claims the key while an archive can be undone; otherwise text undo runs as usual.
{
id: "workspace-archive-undo-cmd-z-mac",
action: "workspace.archive.undo",
combo: "Cmd+Z",
when: { mac: true, commandCenter: false, editable: false, terminal: false },
help: {
id: "undo-archive-workspace",
section: "workspaces",
label: "Undo archive workspace",
},
},
{
id: "workspace-archive-undo-ctrl-z-non-mac",
action: "workspace.archive.undo",
combo: "Ctrl+Z",
when: { mac: false, commandCenter: false, editable: false, terminal: false },
help: {
id: "undo-archive-workspace",
section: "workspaces",
label: "Undo archive workspace",
},
},
// --- Pin workspace ---
{
id: "workspace-pin-cmd-shift-p-mac",
@@ -48,7 +48,6 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"workspace.new": { id: "workspace.new", scope: "sidebar" },
"workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" },
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
"workspace.archive.undo": { id: "workspace.archive.undo", scope: "global" },
"workspace.pin": { id: "workspace.pin", scope: "sidebar" },
"worktree.new": { id: "worktree.new", scope: "sidebar" },
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
@@ -9,8 +9,6 @@ import {
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
markWorkspaceArchiveUndoPending,
settleWorkspaceArchiveIntent,
} from "@/contexts/session-workspace-upserts";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { WorkspaceDirectoryReplica } from "./workspace-replica";
@@ -380,53 +378,3 @@ it("restores a missing workspace after an archive failure without replacing a li
store.clearSession(serverId);
}
});
it("keeps a workspace whose archive is being undone when the host reports its removal", () => {
const serverId = "workspace-archive-undo";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const restored = normalizeWorkspaceDescriptor(workspace("restored"));
const undo = markWorkspaceArchiveUndoPending({ serverId, workspaceId: restored.id });
try {
replica.acceptWorkspaces([restored]);
expect(replica.applyDelta({ kind: "remove", id: restored.id })).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(restored.id)).toBe(true);
settleWorkspaceArchiveIntent(undo);
replica.applyDelta({ kind: "remove", id: restored.id });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(restored.id)).toBe(false);
} finally {
settleWorkspaceArchiveIntent(undo);
store.clearSession(serverId);
}
});
it("keeps a workspace whose archive is being undone when a refresh snapshot omits it", () => {
const serverId = "workspace-archive-undo-refresh";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const restored = normalizeWorkspaceDescriptor(workspace("restored"));
const other = normalizeWorkspaceDescriptor(workspace("other"));
const workspaceIds = () => [
...(useSessionStore.getState().sessions[serverId]?.workspaces.keys() ?? []),
];
const undo = markWorkspaceArchiveUndoPending({ serverId, workspaceId: restored.id });
try {
replica.acceptWorkspaces([restored]);
replica.commitSnapshot({ workspaces: new Map([[other.id, other]]), projects: new Map() }, []);
expect(workspaceIds().sort()).toEqual(["other", "restored"]);
settleWorkspaceArchiveIntent(undo);
replica.commitSnapshot({ workspaces: new Map([[other.id, other]]), projects: new Map() }, []);
expect(workspaceIds()).toEqual(["other"]);
} finally {
settleWorkspaceArchiveIntent(undo);
store.clearSession(serverId);
}
});
@@ -14,7 +14,6 @@ import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
shouldSuppressWorkspaceRemovalForLocalUndo,
} from "@/contexts/session-workspace-upserts";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
@@ -108,15 +107,6 @@ export class WorkspaceDirectoryReplica {
workspaces.set(workspaceId, workspace);
}
}
// A snapshot taken while an undo runs can predate the restore; the host publishes the row.
for (const [workspaceId, workspace] of this.workspaces) {
if (
!workspaces.has(workspaceId) &&
shouldSuppressWorkspaceRemovalForLocalUndo({ serverId: this.serverId, workspaceId })
) {
workspaces.set(workspaceId, workspace);
}
}
for (const workspace of workspaces.values()) this.applySetupStatus(workspace);
this.workspaces = workspaces;
this.projects = new Map(snapshot.projects);
@@ -183,12 +173,6 @@ export class WorkspaceDirectoryReplica {
kind: "remove";
},
): DirectoryReplicaMutation[] {
// The archive this remove reports is being undone; the host publishes the restored row.
if (
shouldSuppressWorkspaceRemovalForLocalUndo({ serverId: this.serverId, workspaceId: delta.id })
) {
return [];
}
this.deleteWorkspace(delta.id);
const mutations: DirectoryReplicaMutation[] = [
{ kind: "workspace", type: "delete", id: delta.id },
@@ -137,7 +137,6 @@ import { useWorkspace } from "@/stores/session-store-hooks";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useWorkspaceArchiveUndoRunning } from "@/workspace/archive-undo";
import { useStableEvent } from "@/hooks/use-stable-event";
import { removeResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { createWorkspaceBrowser, useBrowserStore } from "@/desktop/browser/store";
@@ -1250,11 +1249,6 @@ function WorkspaceScreenContent({
const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
);
// An undo puts the row back before the host restores its agents. Their tabs stay until it settles.
const isArchiveUndoRunning = useWorkspaceArchiveUndoRunning({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const workspaceRouteState = useResolvedWorkspaceRouteState({
serverId: normalizedServerId,
workspace: workspaceDescriptor,
@@ -1555,7 +1549,7 @@ function WorkspaceScreenContent({
persistenceKey,
buildWorkspaceTabSnapshot({
agentVisibility: workspaceAgentVisibility,
agentsHydrated: hasHydratedAgents && !isArchiveUndoRunning,
agentsHydrated: hasHydratedAgents,
terminalsHydrated: terminalsQuery.isSuccess,
knownTerminalIds,
standaloneTerminalIds,
@@ -1566,7 +1560,6 @@ function WorkspaceScreenContent({
);
}, [
hasHydratedAgents,
isArchiveUndoRunning,
hasHydratedWorkspaceLayoutStore,
pendingTerminalCreateInput,
createTerminalMutation.isPending,
@@ -1,8 +0,0 @@
export {
archiveWorkspaceWithUndo,
undoLatestWorkspaceArchive,
type UndoableWorkspaceArchiveClient,
} from "./internal/undo-window";
export { WorkspaceArchiveUndoShortcutHost } from "./internal/undo-shortcut-host";
export { supportsWorkspaceArchiveUndo } from "./internal/capability";
export { useWorkspaceArchiveUndoRunning } from "./internal/use-workspace-archive-undo-running";
@@ -1,8 +0,0 @@
import { useSessionStore } from "@/stores/session-store";
export function supportsWorkspaceArchiveUndo(serverId: string): boolean {
return (
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.workspaceArchiveUndo ===
true
);
}
@@ -1,22 +0,0 @@
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { hasUndoableWorkspaceArchive, undoLatestWorkspaceArchive } from "./undo-window";
const UNDO_ARCHIVE_ACTIONS: readonly KeyboardActionId[] = ["workspace.archive.undo"];
/**
* Cmd/Ctrl+Z for the archive toast. Mounted once at the app root because the row that started
* the archive unmounts as soon as its workspace hides. It claims the key only while an archive
* can be undone, so the key falls through to text undo the rest of the time.
*/
export function WorkspaceArchiveUndoShortcutHost(): null {
useKeyboardActionHandler({
handlerId: "workspace-archive-undo-global",
actions: UNDO_ARCHIVE_ACTIONS,
enabled: true,
priority: 0,
isActive: hasUndoableWorkspaceArchive,
handle: undoLatestWorkspaceArchive,
});
return null;
}
@@ -1,301 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ToastApi, ToastShowOptions } from "@/components/toast-host";
import { clearWorkspaceArchivePending } from "@/contexts/session-workspace-upserts";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { seedSessionHosts } from "@/test/seed-session";
import {
archiveWorkspaceWithUndo,
undoLatestWorkspaceArchive,
type UndoableWorkspaceArchiveClient,
} from "@/workspace/archive-undo";
const SERVER_ID = "workspace-archive-undo-window";
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function workspace(id: string): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id,
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/repo/project",
workspaceDirectory: `/repo/project/${id}`,
projectKind: "git",
workspaceKind: "worktree",
name: id,
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
};
}
/** A toast host with one slot, like the app's: a new toast replaces the shown one. */
function createToastHost() {
let shown: { content: ReactNode; options?: ToastShowOptions; dismiss: () => void } | null = null;
const errors: string[] = [];
const api: ToastApi = {
show: (content, options) => {
const previous = shown;
const entry = {
content,
options,
dismiss: () => {
if (shown !== entry) return;
shown = null;
options?.onDismiss?.();
},
};
shown = entry;
previous?.options?.onDismiss?.();
return entry.dismiss;
},
copied: () => {},
error: (message) => {
errors.push(message);
api.show(message);
},
};
return {
api,
errors,
get shownTestID() {
return shown?.options?.testID ?? null;
},
timeOut: () => shown?.dismiss(),
};
}
type ArchivePayload = Awaited<ReturnType<UndoableWorkspaceArchiveClient["archiveWorkspace"]>>;
function createClient() {
const archives = new Map<string, Deferred<ArchivePayload>>();
const undos = new Map<string, Deferred<void>>();
const client: UndoableWorkspaceArchiveClient = {
archiveWorkspace: (workspaceId) => {
const response = deferred<ArchivePayload>();
archives.set(workspaceId, response);
return response.promise;
},
undoWorkspaceArchive: (workspaceId) => {
const response = deferred<void>();
undos.set(workspaceId, response);
return response.promise;
},
};
return {
client,
finishArchive: (workspaceId: string, payload: Partial<ArchivePayload> = {}) =>
archives.get(workspaceId)?.resolve({ error: null, ...payload }),
undoRequested: (workspaceId: string) => undos.has(workspaceId),
finishUndo: (workspaceId: string) => undos.get(workspaceId)?.resolve(),
failUndo: (workspaceId: string, error: Error) => undos.get(workspaceId)?.reject(error),
};
}
function storedWorkspace(id: string): WorkspaceDescriptor | undefined {
return useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get(id);
}
function startArchive(input: {
host: ReturnType<typeof createClient>;
toast: ReturnType<typeof createToastHost>;
workspaceId: string;
committed: string[];
}) {
return archiveWorkspaceWithUndo({
client: input.host.client,
workspace: { serverId: SERVER_ID, workspaceId: input.workspaceId },
toast: input.toast.api,
onArchiveCommitted: () => input.committed.push(input.workspaceId),
});
}
beforeEach(() => {
seedSessionHosts([SERVER_ID]);
useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient);
getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [workspace("a"), workspace("b")]);
});
afterEach(() => {
undoLatestWorkspaceArchive();
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "a" });
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "b" });
seedSessionHosts([]);
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
describe("archiveWorkspaceWithUndo", () => {
it("commits the archive only once its toast is gone", async () => {
const host = createClient();
const toast = createToastHost();
const committed: string[] = [];
const archive = startArchive({ host, toast, workspaceId: "a", committed });
expect(toast.shownTestID).toBe("workspace-archived-toast");
host.finishArchive("a");
await archive;
expect(committed).toEqual([]);
toast.timeOut();
expect({ committed, undone: undoLatestWorkspaceArchive() }).toEqual({
committed: ["a"],
undone: false,
});
});
it("keeps the layout when another client restores the workspace before the toast closes", async () => {
const host = createClient();
const toast = createToastHost();
const committed: string[] = [];
const archive = startArchive({ host, toast, workspaceId: "a", committed });
host.finishArchive("a");
await archive;
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "a" });
getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [workspace("a")]);
toast.timeOut();
expect({ committed, row: storedWorkspace("a") }).toEqual({
committed: [],
row: workspace("a"),
});
});
it("commits when the archive finishes after its toast already timed out", async () => {
const host = createClient();
const toast = createToastHost();
const committed: string[] = [];
const archive = startArchive({ host, toast, workspaceId: "a", committed });
toast.timeOut();
expect(committed).toEqual([]);
host.finishArchive("a");
await archive;
expect(committed).toEqual(["a"]);
});
it("undo during a running archive brings the row back and never commits", async () => {
const host = createClient();
const toast = createToastHost();
const committed: string[] = [];
const archive = startArchive({ host, toast, workspaceId: "a", committed });
expect(storedWorkspace("a")).toBeUndefined();
expect(undoLatestWorkspaceArchive()).toBe(true);
expect({
row: storedWorkspace("a"),
undoRequested: host.undoRequested("a"),
toast: toast.shownTestID,
}).toEqual({ row: workspace("a"), undoRequested: true, toast: null });
host.finishArchive("a", { canceled: true, error: "Workspace archive was undone" });
await archive;
host.finishUndo("a");
toast.timeOut();
expect({ committed, errors: toast.errors, row: storedWorkspace("a") }).toEqual({
committed: [],
errors: [],
row: workspace("a"),
});
});
it("does not claim Cmd+Z when no archive can be undone", async () => {
const host = createClient();
const toast = createToastHost();
expect(undoLatestWorkspaceArchive()).toBe(false);
const archive = startArchive({ host, toast, workspaceId: "a", committed: [] });
host.finishArchive("a");
await archive;
toast.timeOut();
expect(undoLatestWorkspaceArchive()).toBe(false);
expect(host.undoRequested("a")).toBe(false);
});
it("a newer archive takes over undo and commits the older one", async () => {
const host = createClient();
const toast = createToastHost();
const committed: string[] = [];
const first = startArchive({ host, toast, workspaceId: "a", committed });
host.finishArchive("a");
await first;
const second = startArchive({ host, toast, workspaceId: "b", committed });
expect(committed).toEqual(["a"]);
expect(undoLatestWorkspaceArchive()).toBe(true);
expect({ undoA: host.undoRequested("a"), undoB: host.undoRequested("b") }).toEqual({
undoA: false,
undoB: true,
});
host.finishArchive("b", { canceled: true });
await second;
});
it("reports a failed undo and hides the row again", async () => {
const host = createClient();
const toast = createToastHost();
const archive = startArchive({ host, toast, workspaceId: "a", committed: [] });
host.finishArchive("a");
await archive;
undoLatestWorkspaceArchive();
host.failUndo("a", new Error("Branch is gone"));
await expect.poll(() => toast.errors).toEqual(["Couldn't undo the archive"]);
expect(storedWorkspace("a")).toBeUndefined();
});
it("stays quiet about an archive that failed after the user undid it", async () => {
const host = createClient();
const toast = createToastHost();
const archive = startArchive({ host, toast, workspaceId: "a", committed: [] });
undoLatestWorkspaceArchive();
host.finishArchive("a", { error: "registry unavailable" });
await expect(archive).resolves.toBeUndefined();
expect(toast.errors).toEqual([]);
});
it("rejects a failed archive, closes its toast and puts the row back", async () => {
const host = createClient();
const toast = createToastHost();
const archive = startArchive({ host, toast, workspaceId: "a", committed: [] });
host.finishArchive("a", { error: "registry unavailable" });
await expect(archive).rejects.toThrow("registry unavailable");
expect({
toast: toast.shownTestID,
undone: undoLatestWorkspaceArchive(),
row: storedWorkspace("a"),
}).toEqual({ toast: null, undone: false, row: workspace("a") });
});
});
@@ -1,129 +0,0 @@
import type { DismissToast, ToastApi } from "@/components/toast-host";
import { i18n } from "@/i18n/i18next";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
beginOptimisticWorkspaceArchive,
isWorkspaceListed,
undoWorkspaceArchiveOptimistically,
type WorkspaceArchiveTarget,
} from "@/workspace/workspace-archive";
import { WorkspaceArchivedToast } from "./workspace-archived-toast";
/** How long the archived toast, and with it the chance to undo, stays up. */
export const WORKSPACE_ARCHIVE_UNDO_WINDOW_MS = 6000;
export interface UndoableWorkspaceArchiveClient {
archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null; canceled?: boolean }>;
undoWorkspaceArchive: (workspaceId: string) => Promise<void>;
}
interface UndoWindowInput {
client: UndoableWorkspaceArchiveClient;
workspace: WorkspaceArchiveTarget;
toast: ToastApi;
hidden: WorkspaceDescriptor | null;
onArchiveCommitted: () => void;
}
let latest: UndoWindow | null = null;
/**
* One archive's chance to be undone. It opens when the row hides and closes when its toast
* leaves the screen (timeout, replaced by another toast, or undo).
*
* - `open`: undo is offered.
* - `closed`: undo is no longer offered; the archive stands.
* - `undone`: the user took the archive back.
*/
class UndoWindow {
phase: "open" | "closed" | "undone" = "open";
private archived = false;
private readonly dismissToast: DismissToast;
constructor(private readonly input: UndoWindowInput) {
this.dismissToast = input.toast.show(<WorkspaceArchivedToast onUndo={this.undo} />, {
durationMs: WORKSPACE_ARCHIVE_UNDO_WINDOW_MS,
testID: "workspace-archived-toast",
onDismiss: this.close,
});
}
/** The host finished the archive. It commits now if undo is no longer offered. */
archiveFinished(): void {
this.archived = true;
if (this.phase === "closed") this.commit();
}
/** The archive failed on the host, so there is nothing left to undo. */
archiveFailed(): void {
this.close();
this.dismissToast();
}
readonly close = (): void => {
if (this.phase !== "open") return;
this.phase = "closed";
this.release();
if (this.archived) this.commit();
};
/** Commits unless the workspace is back by now, for example restored from another client. */
private commit(): void {
if (isWorkspaceListed(this.input.workspace)) return;
this.input.onArchiveCommitted();
}
readonly undo = (): boolean => {
if (this.phase !== "open") return false;
this.phase = "undone";
this.release();
this.dismissToast();
void undoWorkspaceArchiveOptimistically(this.input).catch((error: unknown) => {
console.warn("[workspace-archive-undo] undo failed", error);
this.input.toast.error(i18n.t("sidebar.workspace.toasts.undoArchiveFailed"));
});
return true;
};
private release(): void {
if (latest === this) latest = null;
}
}
/**
* Archives a workspace and offers to undo it until the toast goes away.
*
* `onArchiveCommitted` runs once the archive both finished on the host and can no longer be
* undone, and only if the workspace is still gone then. Local state the undo would want back
* (tabs, layout) survives the window, and survives a restore from another client.
* Rejects when the archive fails and was not undone; the row is back by then.
*/
export async function archiveWorkspaceWithUndo(input: {
client: UndoableWorkspaceArchiveClient;
workspace: WorkspaceArchiveTarget;
toast: ToastApi;
onArchiveCommitted: () => void;
}): Promise<void> {
const archive = beginOptimisticWorkspaceArchive(input);
const window = new UndoWindow({ ...input, hidden: archive.hidden });
latest = window;
let outcome: Awaited<typeof archive.done>;
try {
outcome = await archive.done;
} catch (error) {
if (window.phase === "undone") return;
window.archiveFailed();
throw error;
}
if (outcome === "archived") window.archiveFinished();
}
/** Undoes the most recent archive while its toast is up. Returns false when there is none. */
export function undoLatestWorkspaceArchive(): boolean {
return latest ? latest.undo() : false;
}
export function hasUndoableWorkspaceArchive(): boolean {
return latest !== null;
}
@@ -1,21 +0,0 @@
import { useCallback, useSyncExternalStore } from "react";
import {
isWorkspaceArchiveUndoPending,
subscribeWorkspaceArchiveIntents,
} from "@/contexts/session-workspace-upserts";
/**
* True while this client's undo of the workspace's archive is running on the host. Until it
* settles, the workspace's agents may still read as archived even though they are coming back.
*/
export function useWorkspaceArchiveUndoRunning(input: {
serverId: string;
workspaceId: string | null;
}): boolean {
const { serverId, workspaceId } = input;
const read = useCallback(
() => isWorkspaceArchiveUndoPending({ serverId, workspaceId }),
[serverId, workspaceId],
);
return useSyncExternalStore(subscribeWorkspaceArchiveIntents, read, read);
}
@@ -1,41 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) {
const { t } = useTranslation();
const undoKeys = useShortcutKeys("undo-archive-workspace");
const undoShortcut = useMemo(() => (undoKeys ? <Shortcut chord={undoKeys} /> : null), [undoKeys]);
return (
<View style={styles.row}>
<Text style={styles.message}>{t("sidebar.workspace.toasts.archived")}</Text>
<Button
size="sm"
onPress={onUndo}
testID="workspace-archived-toast-undo"
trailing={undoShortcut}
>
{t("sidebar.workspace.toasts.undoArchive")}
</Button>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
message: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -10,7 +10,6 @@ import {
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { archiveWorkspaceWithUndo, supportsWorkspaceArchiveUndo } from "@/workspace/archive-undo";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
@@ -59,22 +58,17 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return;
}
const workspace = { serverId, workspaceId };
onSetHiding?.(true);
try {
onArchiveStarted();
if (supportsWorkspaceArchiveUndo(serverId)) {
await archiveWorkspaceWithUndo({
client,
workspace,
toast,
onArchiveCommitted: () => purgeArchivedWorkspaceState(workspace),
});
} else {
// COMPAT(workspaceArchiveUndo): hosts before v0.15.3 cannot undo; remove after 2027-05-06.
await archiveWorkspaceOptimistically({ client, workspace });
purgeArchivedWorkspaceState(workspace);
}
await archiveWorkspaceOptimistically({
client,
workspace: {
serverId,
workspaceId,
},
});
purgeArchivedWorkspaceState({ serverId, workspaceId });
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
@@ -5,14 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
isWorkspaceArchiveUndoPending,
} from "@/contexts/session-workspace-upserts";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
beginOptimisticWorkspaceArchive,
undoWorkspaceArchiveOptimistically,
type WorkspaceArchiveTarget,
} from "@/workspace/workspace-archive";
@@ -24,14 +21,12 @@ type ArchiveWorkspacePayload = Awaited<ReturnType<DaemonClient["archiveWorkspace
function archivePayload(input: {
workspaceId: string;
error?: string | null;
canceled?: boolean;
}): ArchiveWorkspacePayload {
return {
requestId: "request",
workspaceId: input.workspaceId,
archivedAt: null,
error: input.error ?? null,
...(input.canceled ? { canceled: true } : {}),
};
}
@@ -229,140 +224,3 @@ describe("archiveWorkspacesOptimistically", () => {
expect(storedWorkspaceOn(SECOND_SERVER_ID, second.id)).toBeUndefined();
});
});
describe("undoWorkspaceArchiveOptimistically", () => {
it("brings the row back while the host undoes the archive it is still running", async () => {
const archived = workspace();
getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]);
const archiveResponse = deferred<ArchiveWorkspacePayload>();
const undoResponse = deferred<void>();
const archive = beginOptimisticWorkspaceArchive({
client: createClient(async () => archiveResponse.promise),
workspace: target(),
});
const undo = undoWorkspaceArchiveOptimistically({
client: { undoWorkspaceArchive: async () => undoResponse.promise },
workspace: target(),
hidden: archive.hidden,
});
expect(storedWorkspace(archived.id)).toEqual(archived);
// The host answers the canceled archive before the undo.
archiveResponse.resolve(archivePayload({ workspaceId: archived.id, canceled: true }));
await expect(archive.done).resolves.toBe("canceled");
undoResponse.resolve();
await undo;
expect({
row: storedWorkspace(archived.id),
archivePending: isWorkspaceArchivePending(target()),
undoPending: isWorkspaceArchiveUndoPending(target()),
}).toEqual({ row: archived, archivePending: false, undoPending: false });
});
it("hides the row again when the host cannot undo the archive", async () => {
const archived = workspace();
getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]);
const archive = beginOptimisticWorkspaceArchive({
client: createClient(async () => archivePayload({ workspaceId: archived.id })),
workspace: target(),
});
await archive.done;
await expect(
undoWorkspaceArchiveOptimistically({
client: {
undoWorkspaceArchive: async () => {
throw new Error("Branch is gone");
},
},
workspace: target(),
hidden: archive.hidden,
}),
).rejects.toThrow("Branch is gone");
expect(storedWorkspace(archived.id)).toBeUndefined();
expect(isWorkspaceArchiveUndoPending(target())).toBe(false);
});
it("leaves a re-archived row hidden when the earlier undo settles", async () => {
const archived = workspace();
getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]);
const undoResponse = deferred<void>();
const first = beginOptimisticWorkspaceArchive({
client: createClient(async () => archivePayload({ workspaceId: archived.id })),
workspace: target(),
});
await first.done;
const undo = undoWorkspaceArchiveOptimistically({
client: { undoWorkspaceArchive: async () => undoResponse.promise },
workspace: target(),
hidden: first.hidden,
});
const rearchive = beginOptimisticWorkspaceArchive({
client: createClient(async () => new Promise<ArchiveWorkspacePayload>(() => {})),
workspace: target(),
});
undoResponse.resolve();
await undo;
expect(rearchive.hidden).toEqual(archived);
expect(storedWorkspace(archived.id)).toBeUndefined();
expect(isWorkspaceArchivePending(target())).toBe(true);
});
it("puts the row back when another client's undo cancels this archive", async () => {
const archived = workspace();
getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]);
const archive = beginOptimisticWorkspaceArchive({
client: createClient(async () =>
archivePayload({ workspaceId: archived.id, canceled: true, error: "undone" }),
),
workspace: target(),
});
await expect(archive.done).resolves.toBe("canceled");
expect({
row: storedWorkspace(archived.id),
archivePending: isWorkspaceArchivePending(target()),
}).toEqual({ row: archived, archivePending: false });
});
it("an earlier undo settling keeps holding the row for a later undo", async () => {
const archived = workspace();
getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]);
const firstUndoResponse = deferred<void>();
const secondUndoResponse = deferred<void>();
const first = beginOptimisticWorkspaceArchive({
client: createClient(async () => archivePayload({ workspaceId: archived.id })),
workspace: target(),
});
await first.done;
const firstUndo = undoWorkspaceArchiveOptimistically({
client: { undoWorkspaceArchive: async () => firstUndoResponse.promise },
workspace: target(),
hidden: first.hidden,
});
const second = beginOptimisticWorkspaceArchive({
client: createClient(async () => new Promise<ArchiveWorkspacePayload>(() => {})),
workspace: target(),
});
const secondUndo = undoWorkspaceArchiveOptimistically({
client: { undoWorkspaceArchive: async () => secondUndoResponse.promise },
workspace: target(),
hidden: second.hidden,
});
firstUndoResponse.resolve();
await firstUndo;
expect({
row: storedWorkspace(archived.id),
undoPending: isWorkspaceArchiveUndoPending(target()),
}).toEqual({ row: archived, undoPending: true });
secondUndoResponse.resolve();
await secondUndo;
});
});
+25 -96
View File
@@ -1,10 +1,7 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import {
isWorkspaceArchiveIntentCurrent,
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
markWorkspaceArchiveUndoPending,
settleWorkspaceArchiveIntent,
type WorkspaceArchiveIntent,
} from "@/contexts/session-workspace-upserts";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
@@ -16,25 +13,11 @@ export interface WorkspaceArchiveTarget {
}
interface WorkspaceArchiveClient {
archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null; canceled?: boolean }>;
}
interface WorkspaceArchiveUndoClient {
undoWorkspaceArchive: (workspaceId: string) => Promise<void>;
}
/** `canceled`: an undo stopped the archive on the host before it finished. */
export type WorkspaceArchiveOutcome = "archived" | "canceled";
export interface OptimisticWorkspaceArchive {
/** The row as it was before the archive hid it, for undo to put back. */
hidden: WorkspaceDescriptor | null;
done: Promise<WorkspaceArchiveOutcome>;
archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null }>;
}
interface OptimisticWorkspaceArchiveSnapshot {
workspace: WorkspaceDescriptor | null;
intent: WorkspaceArchiveIntent;
}
export interface WorkspaceArchiveFailure {
@@ -55,42 +38,32 @@ function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFai
);
}
function findListedWorkspace(workspace: WorkspaceArchiveTarget): WorkspaceDescriptor | null {
function hideWorkspaceOptimistically(
workspace: WorkspaceArchiveTarget,
): OptimisticWorkspaceArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: workspace.workspaceId,
});
return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
}
/** True when the workspace is in this client's list, for example after another client restored it. */
export function isWorkspaceListed(workspace: WorkspaceArchiveTarget): boolean {
return findListedWorkspace(workspace) !== null;
}
function hideWorkspaceOptimistically(
workspace: WorkspaceArchiveTarget,
): OptimisticWorkspaceArchiveSnapshot {
const snapshot = findListedWorkspace(workspace);
const intent = markWorkspaceArchivePending({
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
getHostRuntimeStore().removeWorkspaceSnapshot(workspace.serverId, workspace.workspaceId);
return { workspace: snapshot, intent };
return { workspace: snapshot };
}
/**
* Puts back a row whose archive did not happen. Skipped when a newer request on this client
* (an undo, or another archive) owns the row by now.
*/
function restoreOptimisticallyHiddenWorkspace(input: {
serverId: string;
workspaceId: string;
snapshot: OptimisticWorkspaceArchiveSnapshot;
}): void {
if (!isWorkspaceArchiveIntentCurrent(input.snapshot.intent)) return;
settleWorkspaceArchiveIntent(input.snapshot.intent);
clearWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (input.snapshot.workspace) {
getHostRuntimeStore().acceptWorkspaceSnapshots(input.serverId, [input.snapshot.workspace]);
}
@@ -99,75 +72,31 @@ function restoreOptimisticallyHiddenWorkspace(input: {
async function archiveWorkspaceOrThrow(input: {
client: WorkspaceArchiveClient;
workspaceId: string;
}): Promise<WorkspaceArchiveOutcome> {
}): Promise<void> {
const payload = await input.client.archiveWorkspace(input.workspaceId);
if (payload.canceled) {
return "canceled";
}
if (payload.error) {
throw new Error(payload.error);
}
return "archived";
}
/**
* Hides the row now and archives it on the host. A failed archive puts the row back, and so
* does a canceled one: an undo from another client stopped it, so the workspace stays.
*/
export function beginOptimisticWorkspaceArchive(input: {
client: WorkspaceArchiveClient;
workspace: WorkspaceArchiveTarget;
}): OptimisticWorkspaceArchive {
const snapshot = hideWorkspaceOptimistically(input.workspace);
const done = (async () => {
try {
const outcome = await archiveWorkspaceOrThrow({
client: input.client,
workspaceId: input.workspace.workspaceId,
});
if (outcome === "canceled") {
restoreOptimisticallyHiddenWorkspace({ serverId: input.workspace.serverId, snapshot });
}
return outcome;
} catch (error) {
restoreOptimisticallyHiddenWorkspace({ serverId: input.workspace.serverId, snapshot });
throw error;
}
})();
return { hidden: snapshot.workspace, done };
}
export async function archiveWorkspaceOptimistically(input: {
client: WorkspaceArchiveClient;
workspace: WorkspaceArchiveTarget;
}): Promise<WorkspaceArchiveOutcome> {
return beginOptimisticWorkspaceArchive(input).done;
}
/**
* Puts the archived row back now and asks the host to undo the archive, whether it is still
* running or already finished. A failed undo hides the row again, unless the user archived
* it again in the meantime.
*/
export async function undoWorkspaceArchiveOptimistically(input: {
client: WorkspaceArchiveUndoClient;
workspace: WorkspaceArchiveTarget;
hidden: WorkspaceDescriptor | null;
}): Promise<void> {
const { serverId, workspaceId } = input.workspace;
const intent = markWorkspaceArchiveUndoPending({ serverId, workspaceId });
if (input.hidden) {
getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [input.hidden]);
}
const snapshot = hideWorkspaceOptimistically(input.workspace);
try {
await input.client.undoWorkspaceArchive(workspaceId);
await archiveWorkspaceOrThrow({
client: input.client,
workspaceId: input.workspace.workspaceId,
});
} catch (error) {
if (isWorkspaceArchiveIntentCurrent(intent)) {
getHostRuntimeStore().removeWorkspaceSnapshot(serverId, workspaceId);
}
restoreOptimisticallyHiddenWorkspace({
serverId: input.workspace.serverId,
workspaceId: input.workspace.workspaceId,
snapshot,
});
throw error;
} finally {
settleWorkspaceArchiveIntent(intent);
}
}
-17
View File
@@ -3628,23 +3628,6 @@ export class DaemonClient {
}
}
/** Rejects when the host could not put the workspace back; an old host has no such RPC. */
async undoWorkspaceArchive(workspaceId: string, requestId?: string): Promise<void> {
const payload =
await this.sendNamespacedCorrelatedSessionRequest<"workspace.archive.undo.response">({
requestId,
message: {
type: "workspace.archive.undo.request",
workspaceId,
},
// Undoing a finished worktree archive recreates the worktree, like recovery does.
timeout: 150_000,
});
if (!payload.accepted) {
throw new Error(payload.error ?? "The host could not undo the workspace archive");
}
}
async resumeAgent(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
-26
View File
@@ -1105,14 +1105,6 @@ export const WorkspaceRecoveryRestoreRequestSchema = z.object({
requestId: z.string(),
});
// Undoes the latest archive of a workspace: cancels it if it is still running, otherwise
// restores the workspace, then unarchives the agents that archive took down.
export const WorkspaceArchiveUndoRequestSchema = z.object({
type: z.literal("workspace.archive.undo.request"),
workspaceId: z.string(),
requestId: z.string(),
});
export const SetVoiceModeMessageSchema = z.object({
type: z.literal("set_voice_mode"),
enabled: z.boolean(),
@@ -2257,16 +2249,6 @@ export const WorkspaceRecoveryRestoreResponseSchema = z.object({
}),
});
export const WorkspaceArchiveUndoResponseSchema = z.object({
type: z.literal("workspace.archive.undo.response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const SetVoiceModeResponseMessageSchema = z.object({
type: z.literal("set_voice_mode_response"),
payload: z.object({
@@ -3373,7 +3355,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceLabelDeleteInspectRequestSchema,
WorkspaceRecoveryInspectRequestSchema,
WorkspaceRecoveryRestoreRequestSchema,
WorkspaceArchiveUndoRequestSchema,
SetVoiceModeMessageSchema,
SendAgentMessageRequestSchema,
WaitForFinishRequestSchema,
@@ -3839,8 +3820,6 @@ export const ServerInfoStatusPayloadSchema = z
worktreeRestore: z.boolean().optional(),
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
workspaceRecovery: z.boolean().optional(),
// COMPAT(workspaceArchiveUndo): added in v0.15.3, remove gate after 2027-05-06.
workspaceArchiveUndo: z.boolean().optional(),
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: z.boolean().optional(),
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
@@ -4817,8 +4796,6 @@ export const ArchiveWorkspaceResponseMessageSchema = z.object({
workspaceId: z.string(),
archivedAt: z.string().nullable(),
error: z.string().nullable(),
// An undo stopped this archive before it finished; `error` carries the reason.
canceled: z.boolean().optional(),
}),
});
@@ -7254,7 +7231,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceBaseBranchSetResponseSchema,
WorkspaceRecoveryInspectResponseSchema,
WorkspaceRecoveryRestoreResponseSchema,
WorkspaceArchiveUndoResponseSchema,
WaitForFinishResponseMessageSchema,
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
@@ -7468,7 +7444,6 @@ export type WorkspaceRecoveryInspectResponse = z.infer<
export type WorkspaceRecoveryRestoreResponse = z.infer<
typeof WorkspaceRecoveryRestoreResponseSchema
>;
export type WorkspaceArchiveUndoResponse = z.infer<typeof WorkspaceArchiveUndoResponseSchema>;
export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>;
export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
@@ -7618,7 +7593,6 @@ export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema
export type WorkspaceBaseBranchSetRequest = z.infer<typeof WorkspaceBaseBranchSetRequestSchema>;
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
export type WorkspaceArchiveUndoRequest = z.infer<typeof WorkspaceArchiveUndoRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
@@ -6,7 +6,7 @@ import {
} from "./agent-messages/index.js";
import { expect, test, vi, onTestFinished } from "vitest";
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@@ -9466,68 +9466,6 @@ test("archiveAgent detaches a cross-workspace child even when its tab is closed"
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
});
test.each([
{ workspaceId: "workspace-b", open: "false" },
{ workspaceId: "workspace-a", open: "true" },
])(
"archiveAgent publishes stored survivor detach updates ($workspaceId, open=$open)",
async ({ workspaceId, open }) => {
const artifacts = join(process.cwd(), ".artifacts");
mkdirSync(artifacts, { recursive: true });
const workdir = mkdtempSync(join(artifacts, "stored-child-detach-"));
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Parent" },
undefined,
{ workspaceId: "workspace-a" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Closed cross-workspace child" },
undefined,
{
workspaceId,
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: open,
team: "ios",
},
},
);
await manager.closeAgent(child.id);
// Unfiltered, as a session subscribes: a closed survivor's update must reach every client.
const emittedLabels: Array<Record<string, string>> = [];
const unsubscribe = manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === child.id) {
emittedLabels.push(event.agent.labels);
}
},
{ replayState: false },
);
await manager.archiveAgent(parent.id);
await manager.flush();
unsubscribe();
const expectedLabels = { team: "ios" };
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.labels).toEqual(expectedLabels);
expect(emittedLabels).toEqual([expectedLabels]);
// The child is already detached; repeating detach has nothing to sever.
const detached = await manager.detachAgent(child.id);
expect(detached.previousParentAgentId).toBeNull();
expect(detached.record.labels).toEqual(expectedLabels);
},
);
test("archiveAgent re-reads a child before deciding whether to cascade", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-fresh-child-"));
@@ -2112,10 +2112,7 @@ export class AgentManager {
return;
}
if (shouldDetachFromArchivedParent(parent, currentChild)) {
const detached = await this.detachAgentUnlocked(currentChild.id);
if (!detached.live) {
this.dispatchStoredAgentState(detached.record);
}
await this.detachAgentUnlocked(currentChild.id);
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
@@ -2201,7 +2198,7 @@ export class AgentManager {
session: null,
capabilities: STORED_AGENT_CAPABILITIES,
config: buildStoredAgentConfig(record),
runtimeInfo: record.runtimeInfo,
runtimeInfo: undefined,
lifecycle: "closed",
createdAt: new Date(record.createdAt),
updatedAt,
@@ -2543,20 +2540,16 @@ export class AgentManager {
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(record.provider, record.persistence, "restore");
const nextRecord: StoredAgentRecord = {
await registry.upsert({
...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
archivedAt: null,
updatedAt: new Date().toISOString(),
};
await registry.upsert(nextRecord);
});
if (this.getAgent(agentId)) {
this.notifyAgentState(agentId);
} else if (!nextRecord.internal) {
// Same publication archiveSnapshot makes, so a stored-only agent reappears for every client.
this.dispatchStoredAgentState(nextRecord);
}
return true;
}
@@ -219,7 +219,6 @@ const INBOUND_PERMISSION = {
"workspace.base_branch.set.request": "workspace.write",
"workspace.recovery.inspect.request": ["workspace.read", "hub.execute"],
"workspace.recovery.restore.request": ["workspace.manage", "hub.execute"],
"workspace.archive.undo.request": ["workspace.manage", "hub.execute"],
"workspace.script.list.request": "workspace.read",
"workspace.script.start.request": "workspace.write",
"workspace.script.stop.request": "workspace.write",
@@ -463,7 +462,6 @@ const OUTBOUND_PERMISSION = {
"workspace.base_branch.set.response": "workspace.write",
"workspace.recovery.inspect.response": ["workspace.read", "hub.execute"],
"workspace.recovery.restore.response": ["workspace.manage", "hub.execute"],
"workspace.archive.undo.response": ["workspace.manage", "hub.execute"],
"workspace.script.list.response": "workspace.read",
"workspace.script.start.response": "workspace.write",
"workspace.script.stop.response": "workspace.write",
@@ -1,162 +0,0 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { expect, test } from "vitest";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
const CREATED_AT = "2026-11-04T08:00:00.000Z";
const UPDATED_AT = "2026-11-04T09:00:00.000Z";
const RUNTIME_INFO = {
provider: "codex",
sessionId: null,
model: "provider-default",
thinkingOptionId: "medium",
};
test.each([false, true])(
"parent archive preserves a stored survivor for subscribers (filtered=%s)",
async (filtered) => {
const fixture = seedFixture();
let daemon: TestPaseoDaemon | null = null;
let observer: DaemonClient | null = null;
let actor: DaemonClient | null = null;
try {
daemon = await createTestPaseoDaemon({
paseoHomeRoot: fixture.paseoHomeRoot,
staticDir: fixture.staticDir,
cleanup: false,
});
const url = `ws://127.0.0.1:${daemon.port}/ws`;
observer = new DaemonClient({ url });
actor = new DaemonClient({ url });
await observer.connect();
await actor.connect();
const updates: AgentSnapshotPayload[] = [];
const removed: string[] = [];
observer.subscribeRawMessages((message: SessionOutboundMessage) => {
if (message.type === "agent_update" && message.payload.kind === "upsert") {
updates.push(message.payload.agent);
} else if (message.type === "agent_update" && message.payload.kind === "remove") {
removed.push(message.payload.agentId);
}
});
const initial = await observer.fetchAgents({
filter: { includeBackground: true, ...(filtered ? { thinkingOptionId: "medium" } : {}) },
subscribe: {},
});
const initialSurvivor = initial.entries.find(
(entry) => entry.agent.id === "child-survivor",
)?.agent;
expect(initialSurvivor?.runtimeInfo).toEqual(RUNTIME_INFO);
expect(initialSurvivor?.effectiveThinkingOptionId).toBe("medium");
// A child already detached by hand remains independent of the parent archive.
await actor.detachAgent("child-manual");
await actor.archiveAgent("parent");
const survivor = await actor.fetchAgent({ agentId: "child-survivor" });
expect(survivor?.agent.archivedAt).toBeNull();
expect(survivor?.agent.labels).toEqual({});
await expect
.poll(() => updates.findLast((update) => update.id === "child-survivor")?.labels)
.toEqual({});
const update = updates.findLast((item) => item.id === "child-survivor");
expect(update?.runtimeInfo).toEqual(RUNTIME_INFO);
expect(update?.effectiveThinkingOptionId).toBe("medium");
expect(removed).not.toContain("child-survivor");
const manual = await actor.fetchAgent({ agentId: "child-manual" });
expect(manual?.agent.archivedAt).toBeNull();
expect(manual?.agent.labels).toEqual({});
} finally {
await observer?.close().catch(() => undefined);
await actor?.close().catch(() => undefined);
await daemon?.close().catch(() => undefined);
for (const target of fixture.cleanupPaths) rmSync(target, { recursive: true, force: true });
}
},
);
function seedFixture() {
const artifacts = path.join(process.cwd(), ".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "archive-detach-"));
const parentCwd = path.join(root, "parent");
const childCwd = path.join(root, "child");
const paseoHomeRoot = path.join(root, "home");
const staticDir = path.join(root, "static");
mkdirSync(staticDir);
mkdirSync(parentCwd);
mkdirSync(childCwd);
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const projectsDir = path.join(paseoHome, "projects");
const agentsDir = path.join(paseoHome, "agents");
mkdirSync(projectsDir, { recursive: true });
mkdirSync(agentsDir, { recursive: true });
const project = (projectId: string, rootPath: string) => ({
projectId,
rootPath,
kind: "non_git",
displayName: projectId,
customName: null,
createdAt: CREATED_AT,
updatedAt: UPDATED_AT,
archivedAt: null,
});
const workspace = (workspaceId: string, projectId: string, cwd: string) => ({
workspaceId,
projectId,
cwd,
kind: "directory",
displayName: workspaceId,
title: null,
branch: null,
baseBranch: null,
createdAt: CREATED_AT,
updatedAt: UPDATED_AT,
archivedAt: null,
});
writeJson(path.join(projectsDir, "projects.json"), [
project("proj-parent", parentCwd),
project("proj-child", childCwd),
]);
writeJson(path.join(projectsDir, "workspaces.json"), [
workspace("ws-parent", "proj-parent", parentCwd),
workspace("ws-child", "proj-child", childCwd),
]);
const agent = (id: string, cwd: string, workspaceId: string, labels: Record<string, string>) => ({
id,
provider: "codex",
cwd,
workspaceId,
createdAt: CREATED_AT,
updatedAt: UPDATED_AT,
lastActivityAt: UPDATED_AT,
lastUserMessageAt: null,
title: id,
labels,
lastStatus: "closed",
lastModeId: "full-access",
config: null,
...(id === "child-survivor" ? { runtimeInfo: RUNTIME_INFO } : {}),
persistence: null,
});
// Both children live in another workspace, so the parent's archive detaches rather than archives them.
writeJson(path.join(agentsDir, "parent.json"), agent("parent", parentCwd, "ws-parent", {}));
writeJson(
path.join(agentsDir, "child-survivor.json"),
agent("child-survivor", childCwd, "ws-child", { [PARENT_AGENT_ID_LABEL]: "parent" }),
);
writeJson(
path.join(agentsDir, "child-manual.json"),
agent("child-manual", childCwd, "ws-child", { [PARENT_AGENT_ID_LABEL]: "parent" }),
);
return { paseoHomeRoot, staticDir, cleanupPaths: [root] };
}
function writeJson(filePath: string, value: unknown): void {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
@@ -1,124 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { expect, test } from "vitest";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { SessionOutboundMessage } from "../messages.js";
test("a warm reconnect does not resend the unchanged workspace snapshot", async () => {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "workspace-reconnect-"));
const cwd = path.join(root, "repo");
await mkdir(cwd);
execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
await writeFile(path.join(cwd, "README.md"), "initial\n");
execFileSync("git", ["add", "README.md"], { cwd });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"Initial",
],
{ cwd, stdio: "ignore" },
);
const homeRoot = path.join(root, "home");
const registry = path.join(homeRoot, ".paseo", "projects");
await mkdir(registry, { recursive: true });
const timestamps = {
createdAt: "2026-10-01T00:00:00.000Z",
updatedAt: "2026-10-01T00:00:00.000Z",
archivedAt: null,
};
await writeFile(
path.join(registry, "projects.json"),
JSON.stringify([
{
projectId: "prj_reconnect",
rootPath: cwd,
kind: "git",
displayName: "Reconnect",
...timestamps,
},
]),
);
await writeFile(
path.join(registry, "workspaces.json"),
JSON.stringify(
Array.from({ length: 3 }, (_, index) => ({
workspaceId: `wks_reconnect_${index}`,
projectId: "prj_reconnect",
cwd,
kind: "local_checkout",
displayName: `Workspace ${index}`,
title: `Workspace ${index}`,
...timestamps,
})),
),
);
const daemon = await createTestPaseoDaemon({
paseoHomeRoot: homeRoot,
staticDir: path.join(root, "static"),
cleanup: false,
agentClients: {},
pluginsEnabled: false,
});
const clients: DaemonClient[] = [];
try {
const warm = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
clients.push(warm);
await warm.connect();
await warm.getCheckoutStatus(cwd);
await warm.close();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
clients.push(client);
await client.connect();
const updates: Extract<SessionOutboundMessage, { type: "workspace_update" }>[] = [];
const events = client.observeEvents(["checkout_status_update"]);
events.subscribe({ snapshot: () => {}, update: () => {} });
await events.ready;
const snapshot = await client.fetchWorkspaces({ sync: {}, subscribe: {} });
snapshot.subscription.subscribe({
snapshot() {},
update(message) {
if (message.type === "workspace_update") updates.push(message);
},
});
expect(snapshot.entries).toHaveLength(3);
expect(snapshot.entries[0]?.gitRuntime?.currentBranch).toBe("main");
await client.getCheckoutStatus(cwd);
await new Promise((resolve) => setTimeout(resolve, 1000));
await writeFile(
path.join(root, "observed.json"),
JSON.stringify({ snapshot, updates }, null, 2),
);
const values = new Map(
snapshot.entries.map(({ syncSeq: _, ...workspace }) => [
workspace.id,
JSON.parse(JSON.stringify(workspace)),
]),
);
const identical = updates.filter(
({ payload }) =>
payload.kind === "upsert" &&
isDeepStrictEqual(
JSON.parse(JSON.stringify(payload.workspace)),
values.get(payload.workspace.id),
),
);
expect(identical, "workspace_update duplicates the snapshot without a data change").toEqual([]);
} finally {
await Promise.all(clients.map((client) => client.close()));
await daemon.close();
}
}, 30_000);
+32 -137
View File
@@ -278,10 +278,6 @@ import {
handleWorkspaceSetupRunRequest as handleWorkspaceSetupRunRequestMessage,
} from "./worktree-session.js";
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
import {
WorkspaceArchiveOperations,
type WorkspaceArchiveUndoInput,
} from "./workspace-archive-operations.js";
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
import { SessionAuthorization, type DaemonPermission } from "./authorization/index.js";
@@ -290,12 +286,6 @@ function resolveWorkspaceSetupRuntime(
): WorkspaceSetupRuntime {
return runtime ?? new WorkspaceSetupRuntime();
}
function resolveWorkspaceArchiveOperations(
operations: WorkspaceArchiveOperations | undefined,
): WorkspaceArchiveOperations {
return operations ?? new WorkspaceArchiveOperations();
}
import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import {
@@ -576,8 +566,6 @@ export interface SessionOptions {
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
workspaceSetupObservation?: WorkspaceSetupObservation;
workspaceSetupRuntime?: WorkspaceSetupRuntime;
/** Shared by every session so an undo from any connection reaches the archive it cancels. */
workspaceArchiveOperations?: WorkspaceArchiveOperations;
onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
@@ -849,7 +837,6 @@ export class Session {
private readonly workspaceSetupObservation: WorkspaceSetupObservation;
private readonly unsubscribeSetupObservation: () => void;
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
private readonly workspaceArchiveOperations: WorkspaceArchiveOperations;
private readonly workspaceGitObserver: WorkspaceGitObserverService;
private readonly workspaceDirectory: WorkspaceDirectory;
private readonly voiceSessions: VoiceSessions;
@@ -915,7 +902,6 @@ export class Session {
scriptRuntimeStore,
workspaceSetupObservation,
workspaceSetupRuntime,
workspaceArchiveOperations,
onBranchChanged,
getDaemonTcpPort,
getDaemonTcpHost,
@@ -1263,7 +1249,6 @@ export class Session {
},
);
this.workspaceSetupRuntime = resolveWorkspaceSetupRuntime(workspaceSetupRuntime);
this.workspaceArchiveOperations = resolveWorkspaceArchiveOperations(workspaceArchiveOperations);
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null;
@@ -3321,8 +3306,6 @@ export class Session {
return this.handleWorkspaceRecoveryInspectRequest(msg);
case "workspace.recovery.restore.request":
return this.handleWorkspaceRecoveryRestoreRequest(msg);
case "workspace.archive.undo.request":
return this.handleWorkspaceArchiveUndoRequest(msg);
case "workspace.clear_attention.request":
return this.handleWorkspaceClearAttentionRequest(msg);
case "workspace.mark_unread.request":
@@ -6197,10 +6180,6 @@ export class Session {
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
await this.workspaceRecovery.restore(workspaceId);
await this.publishRecoveredWorkspace(workspaceId);
}
private async publishRecoveredWorkspace(workspaceId: string): Promise<void> {
const workspace = await this.workspaceRegistry.get(workspaceId);
if (!workspace) {
throw new Error(`Recovered workspace record not found: ${workspaceId}`);
@@ -7807,62 +7786,41 @@ export class Session {
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
): Promise<void> {
try {
// Claim the archive queue before any await, so an undo sent right behind this request
// queues after it and cancels it instead of running first.
const outcome = await this.workspaceArchiveOperations.archive(
request.workspaceId,
async (signal) => {
const existing = await this.workspaceRegistry.get(request.workspaceId);
if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
return archiveByScope(
{
paseoHome: this.paseoHome,
paseoWorktreesBaseRoot: this.worktreesRoot,
github: this.github,
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
this.markWorkspaceArchiving(workspaceIds, archivingAt),
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
assertWorkspaceAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
killTerminalsForWorkspace: (workspaceId) =>
this.terminalController.killTerminalsForWorkspace(workspaceId),
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
sessionLogger: this.sessionLogger,
},
{
scope: { kind: "workspace", workspaceId: existing.workspaceId },
requestId: request.requestId,
signal,
},
);
const existing = await this.workspaceRegistry.get(request.workspaceId);
if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
await archiveByScope(
{
paseoHome: this.paseoHome,
paseoWorktreesBaseRoot: this.worktreesRoot,
github: this.github,
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
this.markWorkspaceArchiving(workspaceIds, archivingAt),
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
assertWorkspaceAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
killTerminalsForWorkspace: (workspaceId) =>
this.terminalController.killTerminalsForWorkspace(workspaceId),
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
sessionLogger: this.sessionLogger,
},
{
scope: { kind: "workspace", workspaceId: existing.workspaceId },
requestId: request.requestId,
},
);
if (outcome.kind === "canceled") {
this.emit({
type: "archive_workspace_response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
archivedAt: null,
error: "Workspace archive was undone",
canceled: true,
},
});
return;
}
const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId);
const archivedAt = archivedWorkspace?.archivedAt ?? new Date().toISOString();
this.emit({
@@ -7892,69 +7850,6 @@ export class Session {
}
}
private async handleWorkspaceArchiveUndoRequest(
request: Extract<SessionInboundMessage, { type: "workspace.archive.undo.request" }>,
): Promise<void> {
try {
await this.workspaceArchiveOperations.undo(request.workspaceId, (input) =>
this.restoreArchivedWorkspaceAndAgents(request.workspaceId, input),
);
this.emit({
type: "workspace.archive.undo.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
accepted: true,
error: null,
},
});
} catch (error) {
this.sessionLogger.warn(
{ err: error, workspaceId: request.workspaceId, requestId: request.requestId },
"session: workspace.archive.undo.request rejected",
);
this.emit({
type: "workspace.archive.undo.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
accepted: false,
error: getErrorMessageOr(error, "Failed to undo the workspace archive"),
},
});
}
}
// Runs on the archive lane after any in-flight archive has stopped, so the record reflects how
// far it got: still active when it was canceled early, archived (directory kept or removed)
// otherwise.
private async restoreArchivedWorkspaceAndAgents(
workspaceId: string,
input: WorkspaceArchiveUndoInput,
): Promise<void> {
const workspace = await this.workspaceRegistry.get(workspaceId);
if (!workspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
if (workspace.archivedAt) {
await this.workspaceRecovery.restore(workspaceId);
}
const results = await Promise.allSettled(
input.agentIds.map((agentId) =>
unarchiveAgentState(this.agentStorage, this.agentManager, agentId),
),
);
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
this.sessionLogger.warn(
{ err: result.reason, workspaceId, agentId: input.agentIds[index] },
"Failed to unarchive agent while undoing workspace archive",
);
}
}
await this.publishRecoveredWorkspace(workspaceId);
}
private async handleWorkspaceClearAttentionRequest(
request: Extract<SessionInboundMessage, { type: "workspace.clear_attention.request" }>,
): Promise<void> {
@@ -65,7 +65,6 @@ import {
import type { HubRelationshipManagement } from "./hub/relationship-controller.js";
import { WorkspaceSetupObservation } from "./workspace-setup-observation.js";
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
import { WorkspaceArchiveOperations } from "./workspace-archive-operations.js";
import type { HubExecutionAgents } from "./hub/daemon-executions.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
@@ -610,9 +609,6 @@ export class VoiceAssistantWebSocketServer {
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly workspaceSetupObservation = new WorkspaceSetupObservation();
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
// One lane per workspace across every connection, so an undo from any client can cancel an
// archive another connection started.
private readonly workspaceArchiveOperations = new WorkspaceArchiveOperations();
private readonly providerSnapshotManager: ProviderSnapshotManager;
private onLifecycleIntent!: ((intent: SessionLifecycleIntent) => void) | null;
private onBranchChanged!:
@@ -1701,7 +1697,6 @@ export class VoiceAssistantWebSocketServer {
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
workspaceSetupObservation: this.workspaceSetupObservation,
workspaceSetupRuntime: this.workspaceSetupRuntime,
workspaceArchiveOperations: this.workspaceArchiveOperations,
onBranchChanged: this.onBranchChanged ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
@@ -2125,8 +2120,6 @@ export class VoiceAssistantWebSocketServer {
worktreeRestore: true,
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
workspaceRecovery: true,
// COMPAT(workspaceArchiveUndo): added in v0.15.3, remove gate after 2027-05-06.
workspaceArchiveUndo: true,
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: true,
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
@@ -1,282 +0,0 @@
import { describe, expect, test } from "vitest";
import { WorkspaceArchiveOperations } from "./workspace-archive-operations.js";
import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-archive-service.js";
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function deferred<T = void>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function archived(newlyArchivedAgentIds: string[]): ArchiveResult {
return {
archivedAgentIds: newlyArchivedAgentIds,
newlyArchivedAgentIds,
archivedWorkspaceIds: ["ws"],
removedDirectory: false,
};
}
/**
* An archive that parks until released, then honours its abort signal the way archiveByScope's
* checkpoints do: an aborted run reports the agents it already took down.
*/
function parkedArchive(agentIds: string[]) {
const release = deferred();
const started = deferred();
let signal: AbortSignal | null = null;
return {
release: () => release.resolve(),
started: started.promise,
get aborted() {
return signal?.aborted ?? false;
},
run: async (runSignal: AbortSignal) => {
signal = runSignal;
started.resolve();
await release.promise;
if (runSignal.aborted) throw new WorkspaceArchiveCanceledError(agentIds, false);
return archived(agentIds);
},
};
}
describe("WorkspaceArchiveOperations", () => {
test("an undo during a running archive aborts it and restores the agents it took down", async () => {
const operations = new WorkspaceArchiveOperations();
const archive = parkedArchive(["agent-a", "agent-b"]);
const restored: string[][] = [];
const archiveDone = operations.archive("ws", archive.run);
await archive.started;
const undoDone = operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
});
expect(archive.aborted).toBe(true);
expect(restored).toEqual([]);
archive.release();
await expect(archiveDone).resolves.toEqual({ kind: "canceled" });
await undoDone;
expect(restored).toEqual([["agent-a", "agent-b"]]);
});
test("an undo after the archive finished restores the agents that archive took down", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
const restored: string[][] = [];
await operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
});
expect(restored).toEqual([["agent-a"]]);
});
test("restore runs only after the aborted archive has stopped", async () => {
const operations = new WorkspaceArchiveOperations();
const archive = parkedArchive([]);
const order: string[] = [];
const archiveDone = operations
.archive("ws", async (signal) => {
try {
return await archive.run(signal);
} finally {
order.push("archive stopped");
}
})
.catch(() => undefined);
await archive.started;
const undoDone = operations.undo("ws", async () => {
order.push("restore");
});
archive.release();
await Promise.all([archiveDone, undoDone]);
expect(order).toEqual(["archive stopped", "restore"]);
});
test("a second archive while one runs joins it instead of archiving twice", async () => {
const operations = new WorkspaceArchiveOperations();
const archive = parkedArchive(["agent-a"]);
let runs = 0;
const run = (signal: AbortSignal) => {
runs += 1;
return archive.run(signal);
};
const first = operations.archive("ws", run);
const second = operations.archive("ws", run);
archive.release();
await expect(Promise.all([first, second])).resolves.toEqual([
{ kind: "archived", result: archived(["agent-a"]) },
{ kind: "archived", result: archived(["agent-a"]) },
]);
expect(runs).toBe(1);
});
test("an archive requested during an undo runs after the undo, and wins", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
const restoreGate = deferred();
const order: string[] = [];
const undoDone = operations.undo("ws", async () => {
await restoreGate.promise;
order.push("restored");
});
const rearchive = operations.archive("ws", async () => {
order.push("archived again");
return archived(["agent-a"]);
});
restoreGate.resolve();
await undoDone;
await expect(rearchive).resolves.toMatchObject({ kind: "archived" });
expect(order).toEqual(["restored", "archived again"]);
});
test("archive, undo, archive in quick succession leaves the workspace archived", async () => {
const operations = new WorkspaceArchiveOperations();
const first = parkedArchive(["agent-a"]);
const restored: string[][] = [];
const firstDone = operations.archive("ws", first.run);
await first.started;
const undoDone = operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
});
// The re-archive must not join the archive the undo just aborted.
const secondDone = operations.archive("ws", async () => archived(["agent-a"]));
first.release();
await expect(firstDone).resolves.toEqual({ kind: "canceled" });
await undoDone;
await expect(secondDone).resolves.toMatchObject({ kind: "archived" });
expect(restored).toEqual([["agent-a"]]);
});
test("a second undo while one runs joins it", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
const restoreGate = deferred();
let restores = 0;
const restore = async () => {
restores += 1;
await restoreGate.promise;
};
const first = operations.undo("ws", restore);
const second = operations.undo("ws", restore);
restoreGate.resolve();
await Promise.all([first, second]);
expect(restores).toBe(1);
});
test("a failed restore keeps the agents for a retry", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
await expect(
operations.undo("ws", async () => {
throw new Error("worktree recreation failed");
}),
).rejects.toThrow("worktree recreation failed");
const restored: string[][] = [];
await operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
});
expect(restored).toEqual([["agent-a"]]);
});
test("an archive of an already archived workspace keeps the earlier archive's agents for undo", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
await operations.archive("ws", async () => ({
archivedAgentIds: ["agent-a"],
newlyArchivedAgentIds: [],
archivedWorkspaceIds: [],
removedDirectory: false,
}));
const restored: string[][] = [];
await operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
});
expect(restored).toEqual([["agent-a"]]);
});
test("an undo restores the archive's agents once; a repeat undo restores none", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
const restored: string[][] = [];
const restore = async ({ agentIds }: { agentIds: string[] }) => {
restored.push(agentIds);
};
await operations.undo("ws", restore);
await operations.undo("ws", restore);
expect(restored).toEqual([["agent-a"], []]);
});
test("an undo after the retention window restores the workspace without agents", async () => {
let now = 0;
const operations = new WorkspaceArchiveOperations({ undoRetentionMs: 1000, now: () => now });
await operations.archive("ws", async () => archived(["agent-a"]));
now = 1000;
const restored: string[][] = [];
await operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
});
expect(restored).toEqual([[]]);
});
test("workspaces have independent lanes", async () => {
const operations = new WorkspaceArchiveOperations();
const archive = parkedArchive(["agent-a"]);
const parked = operations.archive("ws-a", archive.run);
await archive.started;
await expect(operations.archive("ws-b", async () => archived(["agent-b"]))).resolves.toEqual({
kind: "archived",
result: archived(["agent-b"]),
});
await operations.undo("ws-b", async () => {});
expect(archive.aborted).toBe(false);
archive.release();
await expect(parked).resolves.toMatchObject({ kind: "archived" });
});
test("a failed archive frees the lane for the next request", async () => {
const operations = new WorkspaceArchiveOperations();
await expect(
operations.archive("ws", async () => {
throw new Error("registry unavailable");
}),
).rejects.toThrow("registry unavailable");
await expect(operations.archive("ws", async () => archived([]))).resolves.toMatchObject({
kind: "archived",
});
});
});
@@ -1,133 +0,0 @@
import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-archive-service.js";
export type WorkspaceArchiveOutcome =
| { kind: "archived"; result: ArchiveResult }
| { kind: "canceled" };
export interface WorkspaceArchiveUndoInput {
/** Agents the undone archive took down. Empty when the daemon no longer remembers it. */
agentIds: string[];
}
type LaneEntry =
| { kind: "archiving"; controller: AbortController; done: Promise<WorkspaceArchiveOutcome> }
| { kind: "undoing"; done: Promise<void> };
interface UndoableArchive {
agentIds: string[];
expiresAt: number;
}
const DEFAULT_UNDO_RETENTION_MS = 10 * 60_000;
/**
* One lane per workspace for archive and its undo, shared by every session on the daemon.
*
* - An archive while one runs joins it.
* - An undo while an archive runs aborts it, waits for it to stop, then restores.
* - An archive while an undo runs waits for the undo, then archives.
* - An undo while an undo runs joins it.
*
* Each call claims the lane synchronously, so whichever request arrives last decides the
* workspace's final state no matter how the earlier work interleaves.
*
* The agents an archive took down are kept in memory for the undo window. An archive that took
* nothing down, such as a repeat archive from a stale client, leaves them alone. After a daemon
* restart, undo still restores the workspace but its agents stay archived.
*/
export class WorkspaceArchiveOperations {
private readonly lanes = new Map<string, LaneEntry>();
private readonly undoable = new Map<string, UndoableArchive>();
private readonly undoRetentionMs: number;
private readonly now: () => number;
constructor(options: { undoRetentionMs?: number; now?: () => number } = {}) {
this.undoRetentionMs = options.undoRetentionMs ?? DEFAULT_UNDO_RETENTION_MS;
this.now = options.now ?? Date.now;
}
archive(
workspaceId: string,
run: (signal: AbortSignal) => Promise<ArchiveResult>,
): Promise<WorkspaceArchiveOutcome> {
const current = this.lanes.get(workspaceId);
if (current?.kind === "archiving") {
return current.done;
}
const controller = new AbortController();
const done = (async (): Promise<WorkspaceArchiveOutcome> => {
await settled(current?.done);
try {
const result = await run(controller.signal);
if (result.archivedWorkspaceIds.length > 0 || result.newlyArchivedAgentIds.length > 0) {
this.retainUndoable(workspaceId, result.newlyArchivedAgentIds);
}
return { kind: "archived", result };
} catch (error) {
if (!(error instanceof WorkspaceArchiveCanceledError)) throw error;
if (error.recordArchived || error.newlyArchivedAgentIds.length > 0) {
this.retainUndoable(workspaceId, error.newlyArchivedAgentIds);
}
return { kind: "canceled" };
}
})();
this.claim(workspaceId, { kind: "archiving", controller, done });
return done;
}
undo(
workspaceId: string,
restore: (input: WorkspaceArchiveUndoInput) => Promise<void>,
): Promise<void> {
const current = this.lanes.get(workspaceId);
if (current?.kind === "undoing") {
return current.done;
}
current?.controller.abort();
const done = (async () => {
await settled(current?.done);
const agentIds = this.takeUndoable(workspaceId);
try {
await restore({ agentIds });
} catch (error) {
// Keep the agents for a retry; the archive they came from is still the latest one.
this.retainUndoable(workspaceId, agentIds);
throw error;
}
})();
this.claim(workspaceId, { kind: "undoing", done });
return done;
}
private claim(workspaceId: string, entry: LaneEntry): void {
this.lanes.set(workspaceId, entry);
const release = () => {
if (this.lanes.get(workspaceId) === entry) this.lanes.delete(workspaceId);
};
void (entry.done as Promise<unknown>).then(release, release);
}
private retainUndoable(workspaceId: string, agentIds: string[]): void {
const now = this.now();
for (const [id, entry] of this.undoable) {
if (entry.expiresAt <= now) this.undoable.delete(id);
}
this.undoable.set(workspaceId, { agentIds, expiresAt: now + this.undoRetentionMs });
}
private takeUndoable(workspaceId: string): string[] {
const entry = this.undoable.get(workspaceId);
this.undoable.delete(workspaceId);
if (!entry || entry.expiresAt <= this.now()) return [];
return entry.agentIds;
}
}
async function settled(promise: Promise<unknown> | undefined): Promise<void> {
if (!promise) return;
try {
await promise;
} catch {
// The earlier request reports its own failure; this one only waits for the lane.
}
}
@@ -19,7 +19,6 @@ import {
type ArchiveDependencies,
type ArchiveResult,
resolveWorkspaceIdAtPath,
WorkspaceArchiveCanceledError,
} from "./workspace-archive-service.js";
import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js";
@@ -1020,207 +1019,6 @@ describe("archiveByScope", () => {
});
});
function gate(): {
wait: Promise<void>;
open: () => void;
reached: Promise<void>;
reach: () => void;
} {
let open!: () => void;
let reach!: () => void;
const wait = new Promise<void>((resolve) => {
open = resolve;
});
const reached = new Promise<void>((resolve) => {
reach = resolve;
});
return { wait, open, reached, reach };
}
async function canceledArchive(promise: Promise<unknown>): Promise<WorkspaceArchiveCanceledError> {
const error = await promise.then(
() => null,
(reason: unknown) => reason,
);
if (!(error instanceof WorkspaceArchiveCanceledError)) {
throw new Error(`Expected the archive to be canceled, got ${String(error)}`);
}
return error;
}
describe("archiveByScope cancellation", () => {
function storedAgents(
workspaceId: string,
agents: Array<{ id: string; archivedAt: string | null }>,
) {
return {
listByWorkspace: async () =>
agents.map((agent) => ({ ...agent, workspaceId })) as StoredAgentRecord[],
} as Pick<AgentStorage, "listByWorkspace">;
}
test("an abort while agents archive keeps the record and reports only the agents it took down", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-agents");
const workspaceId = "ws-cancel-agents";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
deps.agentStorage = storedAgents(workspaceId, [
{ id: "agent-active", archivedAt: null },
{ id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" },
]);
const agentArchive = gate();
deps.agentManager.archiveSnapshot = vi.fn(async (agentId: string) => {
agentArchive.reach();
await agentArchive.wait;
deps.archivedSnapshotIds.push(agentId);
return {} as StoredAgentRecord;
});
const controller = new AbortController();
const archive = archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
requestId: "req-cancel-agents",
signal: controller.signal,
});
await agentArchive.reached;
controller.abort();
agentArchive.open();
const error = await canceledArchive(archive);
expect({
newlyArchivedAgentIds: error.newlyArchivedAgentIds,
recordArchived: error.recordArchived,
activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId),
directoryExists: existsSync(worktree.worktreePath),
}).toEqual({
newlyArchivedAgentIds: ["agent-active"],
recordArchived: false,
activeWorkspaceIds: [workspaceId],
directoryExists: true,
});
// The archiving marker never outlives a canceled archive.
expect(deps.clearWorkspaceArchiving).toHaveBeenCalledWith([workspaceId]);
});
test("an abort after the record archived keeps the worktree directory", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-record");
const workspaceId = "ws-cancel-record";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
const recordArchive = gate();
const archiveRecord = deps.archiveWorkspaceRecord;
deps.archiveWorkspaceRecord = async (id: string) => {
recordArchive.reach();
await recordArchive.wait;
await archiveRecord(id);
};
const controller = new AbortController();
const archive = archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
requestId: "req-cancel-record",
signal: controller.signal,
});
await recordArchive.reached;
controller.abort();
recordArchive.open();
const error = await canceledArchive(archive);
expect({
recordArchived: error.recordArchived,
activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId),
directoryExists: existsSync(worktree.worktreePath),
}).toEqual({ recordArchived: true, activeWorkspaceIds: [], directoryExists: true });
});
test("an archive aborted before it starts touches nothing", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-before");
const workspaceId = "ws-cancel-before";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
deps.agentStorage = storedAgents(workspaceId, [{ id: "agent-active", archivedAt: null }]);
const controller = new AbortController();
controller.abort();
const error = await canceledArchive(
archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
requestId: "req-cancel-before",
signal: controller.signal,
}),
);
expect(error.newlyArchivedAgentIds).toEqual([]);
expect(deps.archivedSnapshotIds).toEqual([]);
expect(deps.markWorkspaceArchiving).not.toHaveBeenCalled();
expect(existsSync(worktree.worktreePath)).toBe(true);
});
test("a completed archive reports the agents it newly archived", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "newly-archived");
const workspaceId = "ws-newly-archived";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
deps.agentStorage = storedAgents(workspaceId, [
{ id: "agent-active", archivedAt: null },
{ id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" },
]);
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
requestId: "req-newly-archived",
signal: new AbortController().signal,
});
expect(result.archivedAgentIds.sort()).toEqual(["agent-active", "agent-already-archived"]);
expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]);
});
test("an archived agent loaded to read its history is not reported as newly archived", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "loaded-archived");
const workspaceId = "ws-loaded-archived";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
deps.agentStorage = storedAgents(workspaceId, [
{ id: "agent-active", archivedAt: null },
{ id: "agent-history", archivedAt: "2026-01-01T00:00:00.000Z" },
]);
deps.agentManager.listAgents = () =>
[
{ id: "agent-active", workspaceId },
{ id: "agent-history", workspaceId },
] as ManagedAgent[];
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
requestId: "req-loaded-archived",
signal: new AbortController().signal,
});
expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]);
});
});
describe("resolveWorkspaceIdAtPath", () => {
test("prefers the worktree-kind record on an exact cwd tie", async () => {
const targetPath = "/worktrees/repo/feature";
@@ -67,9 +67,6 @@ export type ArchiveScope =
export interface ArchiveResult {
archivedAgentIds: string[];
// Agents this request moved from active to archived. `archivedAgentIds` also lists agents that
// were already archived; undo must restore only the ones this archive took down.
newlyArchivedAgentIds: string[];
archivedWorkspaceIds: string[];
removedDirectory: boolean;
// Set when the record archived but its worktree directory was left on
@@ -81,24 +78,6 @@ export interface ArchiveResult {
export interface ArchiveByScopeRequest {
scope: ArchiveScope;
requestId: string;
// Aborting cancels the archive at its next checkpoint. Removing the worktree directory is the
// point of no return: once it starts the archive completes.
signal?: AbortSignal;
}
/**
* The archive stopped at a checkpoint because its signal aborted. Whatever ran before the
* checkpoint stays done; `newlyArchivedAgentIds` and `recordArchived` say how far it got so the
* canceller can put it back.
*/
export class WorkspaceArchiveCanceledError extends Error {
constructor(
public readonly newlyArchivedAgentIds: string[],
public readonly recordArchived: boolean,
) {
super("Workspace archive was canceled");
this.name = "WorkspaceArchiveCanceledError";
}
}
export async function requireActiveWorkspaceForArchive(
@@ -165,22 +144,10 @@ async function archiveByScopeWithPriority(
dependencies: ArchiveDependencies,
request: ArchiveByScopeRequest,
): Promise<ArchiveResult> {
const progress: ArchiveProgress = { newlyArchivedAgentIds: new Set(), recordArchived: false };
const checkpoint = () => {
if (request.signal?.aborted) {
throw new WorkspaceArchiveCanceledError(
Array.from(progress.newlyArchivedAgentIds),
progress.recordArchived,
);
}
};
checkpoint();
const target = await resolveArchiveTarget(dependencies, request.scope);
const targetWorkspaceIds = target.workspaceIds;
await stopWorkspaceSetups(dependencies, target.setupWorkspaceIds, request.requestId);
checkpoint();
if (targetWorkspaceIds.length > 0) {
dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString());
@@ -193,14 +160,12 @@ async function archiveByScopeWithPriority(
if (targetWorkspaceIds.length > 0) {
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds);
}
checkpoint();
const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords(
dependencies,
targetWorkspaceIds,
{ requestId: request.requestId, checkpoint, progress },
request.requestId,
);
checkpoint();
if (target.backing?.mainRepoRoot) {
try {
@@ -219,7 +184,7 @@ async function archiveByScopeWithPriority(
if (target.backing !== null) {
const removal = await maybeRemoveDirectory(
dependencies,
{ requestId: request.requestId, checkpoint },
request,
target,
archivedWorkspaceIds,
);
@@ -229,7 +194,6 @@ async function archiveByScopeWithPriority(
return {
archivedAgentIds: Array.from(archivedAgents),
newlyArchivedAgentIds: Array.from(progress.newlyArchivedAgentIds),
archivedWorkspaceIds,
removedDirectory,
...(directoryLeftBehind ? { directoryLeftBehind } : {}),
@@ -242,11 +206,6 @@ async function archiveByScopeWithPriority(
}
}
interface ArchiveProgress {
newlyArchivedAgentIds: Set<string>;
recordArchived: boolean;
}
async function resolveArchiveTarget(
dependencies: ArchiveDependencies,
scope: ArchiveScope,
@@ -393,23 +352,16 @@ async function resolveBackingDirectory(
async function archiveTargetRecords(
dependencies: ArchiveDependencies,
targetWorkspaceIds: string[],
options: { requestId: string; checkpoint: () => void; progress: ArchiveProgress },
requestId: string,
): Promise<{ archivedAgents: Set<string>; archivedWorkspaceIds: string[] }> {
const archivedAgents = new Set<string>();
const archivedWorkspaceIds: string[] = [];
const results = await Promise.allSettled(
targetWorkspaceIds.map(async (workspaceId) => {
const contents = await archiveWorkspaceContentsWithProgress(dependencies, workspaceId);
for (const agentId of contents.newlyArchivedAgentIds) {
options.progress.newlyArchivedAgentIds.add(agentId);
}
// Agents and terminals are down, the record is not: an abort here leaves the workspace
// listed, and the canceller restores the agents it reports.
options.checkpoint();
const agents = await archiveWorkspaceContents(dependencies, workspaceId);
await dependencies.archiveWorkspaceRecord(workspaceId);
options.progress.recordArchived = true;
return { workspaceId, agents: contents.agentIds };
return { workspaceId, agents };
}),
);
@@ -419,14 +371,9 @@ async function archiveTargetRecords(
for (const agentId of result.value.agents) {
archivedAgents.add(agentId);
}
} else if (result.reason instanceof WorkspaceArchiveCanceledError) {
throw new WorkspaceArchiveCanceledError(
Array.from(options.progress.newlyArchivedAgentIds),
options.progress.recordArchived,
);
} else {
dependencies.sessionLogger?.warn(
{ err: result.reason, requestId: options.requestId },
{ err: result.reason, requestId },
"archiveByScope workspace teardown failed; continuing",
);
}
@@ -437,7 +384,7 @@ async function archiveTargetRecords(
async function maybeRemoveDirectory(
dependencies: ArchiveDependencies,
request: { requestId: string; checkpoint: () => void },
request: Pick<ArchiveByScopeRequest, "requestId">,
target: ArchiveTarget,
archivedWorkspaceIds: string[],
): Promise<{
@@ -476,7 +423,6 @@ async function maybeRemoveDirectory(
teardownTargets,
);
for (const teardownTarget of uniqueTeardownTargets(allowedTeardownTargets)) {
request.checkpoint();
await runWorktreeTeardownCommands({
worktreePath: backing.path,
teardownCwd: teardownTarget.cwd,
@@ -506,9 +452,6 @@ async function maybeRemoveDirectory(
return { removedDirectory: false };
}
// Last chance to keep the directory. Teardown commands already ran, so a cancel here restores
// a workspace whose teardown hooks fired; that beats deleting what the user asked to keep.
request.checkpoint();
try {
await deletePaseoWorktree({
cwd: backing.mainRepoRoot,
@@ -575,13 +518,6 @@ export async function archiveWorkspaceContents(
dependencies: ArchiveWorkspaceContentsDependencies,
workspaceId: string,
): Promise<Set<string>> {
return (await archiveWorkspaceContentsWithProgress(dependencies, workspaceId)).agentIds;
}
async function archiveWorkspaceContentsWithProgress(
dependencies: ArchiveWorkspaceContentsDependencies,
workspaceId: string,
): Promise<{ agentIds: Set<string>; newlyArchivedAgentIds: string[] }> {
const archivedAgents = new Set<string>();
const liveAgents = dependencies.agentManager
@@ -606,52 +542,29 @@ async function archiveWorkspaceContentsWithProgress(
}
const archivedAt = new Date().toISOString();
const agentIdsToArchive = [
...new Set([
...liveAgents.map((agent) => agent.id),
...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id),
]),
];
// Undo brings back only what this archive took down. Internal agents are retired rather than
// archived, and an agent loaded only to read its history was archived before this request.
const restorableAgentIds = new Set(agentIdsToArchive);
for (const agent of liveAgents) {
if (agent.internal) restorableAgentIds.delete(agent.id);
}
for (const record of matchingStoredRecords) {
if (record.archivedAt) restorableAgentIds.delete(record.id);
}
const [agentResults, [terminalResult]] = await Promise.all([
Promise.allSettled(
agentIdsToArchive.map((agentId) =>
dependencies.agentManager.getAgent(agentId)
? dependencies.agentManager.archiveAgent(agentId)
: dependencies.agentManager.archiveSnapshot(agentId, archivedAt),
),
const agentIdsToArchive = new Set([
...liveAgents.map((agent) => agent.id),
...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id),
]);
const archiveResults = await Promise.allSettled([
...[...agentIdsToArchive].map((agentId) =>
dependencies.agentManager.getAgent(agentId)
? dependencies.agentManager.archiveAgent(agentId)
: dependencies.agentManager.archiveSnapshot(agentId, archivedAt),
),
Promise.allSettled([dependencies.killTerminalsForWorkspace(workspaceId)]),
dependencies.killTerminalsForWorkspace(workspaceId),
]);
const newlyArchivedAgentIds: string[] = [];
for (const [index, result] of agentResults.entries()) {
const agentId = agentIdsToArchive[index]!;
for (const result of archiveResults) {
if (result.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: result.reason, workspaceId, agentId },
{ err: result.reason, workspaceId },
"Workspace archive teardown step failed; continuing",
);
} else if (restorableAgentIds.has(agentId)) {
newlyArchivedAgentIds.push(agentId);
}
}
if (terminalResult?.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: terminalResult.reason, workspaceId },
"Workspace archive teardown step failed; continuing",
);
}
return { agentIds: archivedAgents, newlyArchivedAgentIds };
return archivedAgents;
}
// True when, after archiving
@@ -1,275 +0,0 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
// Undo of a workspace archive, end to end through a real daemon. The archive is parked inside a
// worktree teardown command that waits on a file, so the undo lands at a known point.
let ctx: DaemonTestContext;
const tempRoots: string[] = [];
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
for (const tempRoot of tempRoots.splice(0)) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
interface GatedRepo {
repoDir: string;
teardownStartedPath: string;
releaseTeardown: () => void;
}
function git(repoDir: string, args: string[]): void {
execFileSync("git", args, { cwd: repoDir, stdio: "pipe" });
}
/** A repo whose worktree teardown parks until released. */
function createRepoWithGatedTeardown(): GatedRepo {
const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-archive-undo-"));
tempRoots.push(tempRoot);
const repoDir = path.join(tempRoot, "repo");
const teardownStartedPath = path.join(tempRoot, "teardown-started");
const releasePath = path.join(tempRoot, "release-teardown");
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
git(repoDir, ["config", "user.email", "test@getpaseo.local"]);
git(repoDir, ["config", "user.name", "Paseo Test"]);
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${teardownStartedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
writeFileSync(
path.join(repoDir, "paseo.json"),
JSON.stringify({ worktree: { teardown: [teardown] } }),
);
git(repoDir, ["add", "paseo.json"]);
git(repoDir, ["-c", "commit.gpgsign=false", "commit", "-m", "gated teardown"]);
return {
repoDir,
teardownStartedPath,
releaseTeardown: () => writeFileSync(releasePath, "release\n"),
};
}
async function createWorktreeWorkspaceWithAgent(repoDir: string, slug: string) {
const result = await ctx.client.createWorkspace({
source: { kind: "worktree", cwd: repoDir, worktreeSlug: slug, baseBranch: "main" },
});
const workspace = result.workspace;
if (!workspace?.workspaceDirectory) {
throw new Error(result.error ?? "Failed to create worktree workspace");
}
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd: workspace.workspaceDirectory,
workspaceId: workspace.id,
title: "Undo agent",
});
return { workspaceId: workspace.id, directory: workspace.workspaceDirectory, agentId: agent.id };
}
async function isWorkspaceActive(workspaceId: string): Promise<boolean> {
const workspaces = await ctx.client.fetchWorkspaces();
return workspaces.entries.some((entry) => entry.id === workspaceId);
}
async function isAgentArchived(agentId: string): Promise<boolean> {
const agents = await ctx.client.fetchAgents({ filter: { includeArchived: true } });
const entry = agents.entries.find((candidate) => candidate.agent.id === agentId);
if (!entry) throw new Error(`Agent ${agentId} is missing`);
return entry.agent.archivedAt !== null && entry.agent.archivedAt !== undefined;
}
test.skipIf(process.platform === "win32")(
"an undo while teardown runs cancels the archive before the worktree is deleted",
async () => {
const repo = createRepoWithGatedTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"undo-during-teardown",
);
try {
const archive = ctx.client.archiveWorkspace(workspaceId);
await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true);
const undo = ctx.client.undoWorkspaceArchive(workspaceId);
repo.releaseTeardown();
const [archiveResponse] = await Promise.all([archive, undo]);
expect({
canceled: archiveResponse.canceled,
archivedAt: archiveResponse.archivedAt,
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({
canceled: true,
archivedAt: null,
workspaceActive: true,
directoryExists: true,
agentArchived: false,
});
} finally {
repo.releaseTeardown();
}
},
60000,
);
test.skipIf(process.platform === "win32")(
"an undo after the archive finished recreates the worktree and its agents",
async () => {
const repo = createRepoWithGatedTeardown();
repo.releaseTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"undo-after-archive",
);
const archived = await ctx.client.archiveWorkspace(workspaceId);
expect(archived.error).toBeNull();
expect(existsSync(directory)).toBe(false);
expect(await isAgentArchived(agentId)).toBe(true);
await ctx.client.undoWorkspaceArchive(workspaceId);
expect({
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false });
},
60000,
);
test.skipIf(process.platform === "win32")(
"archive, undo, archive leaves the workspace archived",
async () => {
const repo = createRepoWithGatedTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"archive-undo-archive",
);
try {
const first = ctx.client.archiveWorkspace(workspaceId);
await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true);
const undo = ctx.client.undoWorkspaceArchive(workspaceId);
const second = ctx.client.archiveWorkspace(workspaceId);
repo.releaseTeardown();
const [firstResponse, , secondResponse] = await Promise.all([first, undo, second]);
expect({
firstCanceled: firstResponse.canceled,
secondError: secondResponse.error,
secondCanceled: secondResponse.canceled ?? false,
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({
firstCanceled: true,
secondError: null,
secondCanceled: false,
workspaceActive: false,
directoryExists: false,
agentArchived: true,
});
} finally {
repo.releaseTeardown();
}
},
60000,
);
test.skipIf(process.platform === "win32")(
"an undo sent right behind its archive leaves the workspace active",
async () => {
const repo = createRepoWithGatedTeardown();
repo.releaseTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"archive-then-undo-same-tick",
);
const [archiveResponse] = await Promise.all([
ctx.client.archiveWorkspace(workspaceId),
ctx.client.undoWorkspaceArchive(workspaceId),
]);
expect({
archiveCanceled: archiveResponse.canceled ?? false,
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({
archiveCanceled: true,
workspaceActive: true,
directoryExists: true,
agentArchived: false,
});
},
60000,
);
test.skipIf(process.platform === "win32")(
"the undo response arrives after the client has seen its agents restored",
async () => {
const repo = createRepoWithGatedTeardown();
repo.releaseTeardown();
const { workspaceId, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"undo-update-order",
);
await ctx.client.fetchAgents({ subscribe: {} });
// An archived agent leaves a default subscription; a restored one comes back as active.
let seenState: "active" | "archived" | "removed" = "active";
const stop = ctx.client.on("agent_update", (message) => {
if (message.payload.kind === "remove" && message.payload.agentId === agentId) {
seenState = "removed";
}
if (message.payload.kind === "upsert" && message.payload.agent.id === agentId) {
seenState = message.payload.agent.archivedAt ? "archived" : "active";
}
});
try {
await ctx.client.archiveWorkspace(workspaceId);
await expect.poll(() => seenState, { timeout: 15000 }).not.toBe("active");
await ctx.client.undoWorkspaceArchive(workspaceId);
// An app holds its agent tabs open until this response; an agent it still sees as
// archived at that point gets its tab closed.
expect(seenState).toBe("active");
} finally {
stop();
}
},
60000,
);
test("an undo on a workspace that was never archived leaves it as it was", async () => {
const repo = createRepoWithGatedTeardown();
repo.releaseTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"undo-without-archive",
);
await ctx.client.undoWorkspaceArchive(workspaceId);
expect({
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false });
}, 60000);
@@ -811,78 +811,64 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test.each([0, 100])(
"known packed remote refs materialize narrowly while namespace directories are ignored (watch setup delay %i ms)",
async (setupDelayMs) => {
const watcher = createWatcherHarness();
let fetchStarted = false;
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
currentBranch: "feature",
remoteUrl: REMOTE_URL,
resolvedBaseRef: "main",
comparisonBaseRef: "origin/main",
}));
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
}));
const service = createService(watcher, {
subscribe: async (...args: Parameters<typeof watcher.subscribe>) => {
if (args[0] === GIT_DIR && setupDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, setupDelayMs));
}
return watcher.subscribe(...args);
},
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async (_cwd, observer) => {
fetchStarted = true;
observer.onRefSnapshot("before");
observer.onRefSnapshot("after");
return {
changes: [],
nonRemoteRefsChanged: false,
remoteRefs: new Set(["origin/main", "origin/load/00001"]),
error: null,
};
}),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
// An idle fetch count also holds before observation setup starts.
expect(fetchStarted).toBe(true);
expect(service.getMetrics().fetchInFlightCount).toBe(0);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
const repoWatcher = watcher.records.find((record) => record.directory === GIT_DIR)!;
expect(repoWatcher).toBeDefined();
repoWatcher.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "load"), type: "create" },
{
path: path.join(GIT_DIR, "refs", "remotes", "origin", "load", "00001"),
type: "create",
},
]);
await vi.advanceTimersByTimeAsync(1_000);
test("known packed remote refs materialize narrowly while namespace directories are ignored", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
currentBranch: "feature",
remoteUrl: REMOTE_URL,
resolvedBaseRef: "main",
comparisonBaseRef: "origin/main",
}));
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
}));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async (_cwd, observer) => {
observer.onRefSnapshot("before");
observer.onRefSnapshot("after");
return {
changes: [],
nonRemoteRefsChanged: false,
remoteRefs: new Set(["origin/main", "origin/load/00001"]),
error: null,
};
}),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(0);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
});
repoWatcher.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
const repoWatcher = watcher.records.find((record) => record.directory === GIT_DIR);
repoWatcher?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "load"), type: "create" },
{
path: path.join(GIT_DIR, "refs", "remotes", "origin", "load", "00001"),
type: "create",
},
]);
await vi.advanceTimersByTimeAsync(1_000);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
subscription.unsubscribe();
await service.dispose();
},
);
repoWatcher?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
service.dispose();
});
test("a second remote-ref move during a narrow refresh queues a final calculation", async () => {
const watcher = createWatcherHarness();