mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850471276a | ||
|
|
a5c5ad8e1d | ||
|
|
ad21f9eb93 | ||
|
|
8b5527e7e6 | ||
|
|
04a7d1b3a7 | ||
|
|
e044c788b5 | ||
|
|
986ce285b0 | ||
|
|
e516961a09 | ||
|
|
33db25cf5c | ||
|
|
0a9e678286 | ||
|
|
cee05e3c6c | ||
|
|
92be3e9dff | ||
|
|
8e1e377120 | ||
|
|
870180cbe5 | ||
|
|
567c1617f5 | ||
|
|
236270768d | ||
|
|
1d8eacd38a | ||
|
|
8bc533279a |
@@ -136,12 +136,6 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
|
||||
This is informational ancestry for future workspace grouping. It does not change placement,
|
||||
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
|
||||
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
|
||||
never inferred from directories, projects, or Git branches.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
|
||||
@@ -259,27 +253,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
|
||||
|
||||
+1
-5
@@ -70,10 +70,6 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
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`).
|
||||
|
||||
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.
|
||||
|
||||
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-3
@@ -226,8 +226,7 @@ its own.
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
inactive, and that inactive commit is where the surface unmounts.
|
||||
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
|
||||
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
|
||||
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
|
||||
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
|
||||
card, and are unit-tested. Fix positioning bugs there, not at a call site.
|
||||
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
||||
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
|
||||
|
||||
@@ -235,7 +235,6 @@ async function beginTimelineRowStabilityCheck(
|
||||
lastTop: initialRow.getBoundingClientRect().top,
|
||||
largestDownwardShift: 0,
|
||||
sawMissing: false,
|
||||
sawHidden: false,
|
||||
};
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
windowState.__messageSubmissionTimelineRowStability = state;
|
||||
@@ -246,9 +245,6 @@ async function beginTimelineRowStabilityCheck(
|
||||
requestAnimationFrame(checkFrame);
|
||||
return;
|
||||
}
|
||||
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
|
||||
state.sawHidden = true;
|
||||
}
|
||||
const top = initialRow.getBoundingClientRect().top;
|
||||
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
|
||||
state.lastTop = top;
|
||||
@@ -261,24 +257,14 @@ async function beginTimelineRowStabilityCheck(
|
||||
const result = await page.evaluate(() => {
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
const state = windowState.__messageSubmissionTimelineRowStability as
|
||||
| {
|
||||
active: boolean;
|
||||
largestDownwardShift: number;
|
||||
sawMissing: boolean;
|
||||
sawHidden: boolean;
|
||||
}
|
||||
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
|
||||
| undefined;
|
||||
if (!state) throw new Error("Timeline-row stability check was not started");
|
||||
state.active = false;
|
||||
delete windowState.__messageSubmissionTimelineRowStability;
|
||||
return {
|
||||
largestDownwardShift: state.largestDownwardShift,
|
||||
sawMissing: state.sawMissing,
|
||||
sawHidden: state.sawHidden,
|
||||
};
|
||||
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
|
||||
});
|
||||
expect(result.sawMissing).toBe(false);
|
||||
expect(result.sawHidden).toBe(false);
|
||||
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
|
||||
};
|
||||
}
|
||||
@@ -1192,13 +1178,7 @@ test.describe("Agent message submission", () => {
|
||||
test.setTimeout(120_000);
|
||||
const toasts = await recordPanelToasts(page);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
|
||||
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
|
||||
page,
|
||||
pending.userMessage,
|
||||
);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
await finishTimelineRowStabilityCheck();
|
||||
// A chat this client just created is current by construction; it is never out of date.
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
openChangesPanel,
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
@@ -262,6 +266,126 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes without a comparison base explains how to view pending changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev");
|
||||
await openWorkspaceChangesTree(page, workspace);
|
||||
await expect(
|
||||
page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."),
|
||||
).toBeVisible();
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesTreePanel(page);
|
||||
}
|
||||
|
||||
test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await createLargeStreamedWorkspace();
|
||||
await addOversizedSibling(workspace);
|
||||
const stream = await holdDiffCompletion(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
await expectAnnouncedTreeAndLoader(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tree-loading.png") });
|
||||
stream.finish();
|
||||
await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
const connections = stream.connections;
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1");
|
||||
await addOneLineToFirstFile(workspace);
|
||||
await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1);
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1");
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
expect(stream.connections).toBe(connections);
|
||||
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
|
||||
});
|
||||
|
||||
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(252);
|
||||
await Promise.all(
|
||||
Array.from({ length: 252 }, (_, file) =>
|
||||
writeFile(
|
||||
path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`),
|
||||
Array.from(
|
||||
{ length: 90 },
|
||||
(_unused, line) =>
|
||||
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
|
||||
).join("\n") + "\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
async function addOversizedSibling(workspace: DirtyWorkspace): Promise<void> {
|
||||
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
|
||||
}
|
||||
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
|
||||
const file = path.join(workspace.repoPath, "src/file-0000.ts");
|
||||
await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n");
|
||||
}
|
||||
async function holdDiffCompletion(page: Page) {
|
||||
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
|
||||
let holding = true;
|
||||
let connections = 0;
|
||||
let completed = 0;
|
||||
const completions: Array<() => void> = [];
|
||||
const changedBodies: number[] = [];
|
||||
const bodyCounts = new Map<number, number>();
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
connections++;
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (
|
||||
typeof message !== "string" &&
|
||||
message[0] >= DiffOpcode.Begin &&
|
||||
message[0] <= DiffOpcode.End
|
||||
) {
|
||||
const frame = decodeDiffFrame(message);
|
||||
if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0);
|
||||
else if (frame.opcode === DiffOpcode.Bodies)
|
||||
bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length);
|
||||
else {
|
||||
completed++;
|
||||
changedBodies.push(bodyCounts.get(frame.slot) ?? 0);
|
||||
}
|
||||
}
|
||||
if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) {
|
||||
completions.push(() => browserSocket.send(message));
|
||||
return;
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
changedBodies,
|
||||
get connections() {
|
||||
return connections;
|
||||
},
|
||||
get completed() {
|
||||
return completed;
|
||||
},
|
||||
finish: () => {
|
||||
holding = false;
|
||||
completions.splice(0).forEach((send) => send());
|
||||
},
|
||||
};
|
||||
}
|
||||
async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise<void> {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout });
|
||||
}
|
||||
async function expectAnnouncedTreeAndLoader(page: Page): Promise<void> {
|
||||
await expect(changesTree(page).first()).toBeVisible();
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("Changes opens All changes for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
@@ -490,6 +614,35 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("legacy diff hosts keep All, Committed and Uncommitted comparisons", async ({ page }) => {
|
||||
await useLegacyDiffHost(page);
|
||||
const workspace = await createWorkspaceWithScrollableComparisons();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await expect(changesTree(page).getByText("committed", { exact: true })).toBeVisible();
|
||||
await expect(changesTree(page).getByText("uncommitted", { exact: true })).toBeVisible();
|
||||
await selectChangesComparison(page, "Committed");
|
||||
await expectWorkingComparisonFiles(page, "committed");
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expectWorkingComparisonFiles(page, "uncommitted");
|
||||
});
|
||||
async function useLegacyDiffHost(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.message?.payload?.status === "server_info") {
|
||||
delete envelope.message.payload.features.diffSubscription;
|
||||
browserSocket.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1302,11 +1455,13 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
test.setTimeout(300_000);
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
// Measure document bounds after delivery, independently of the cold Git read.
|
||||
await expectWorkingDiffLoaded(page, 240_000);
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
@@ -1885,12 +2040,16 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
async function createWorkspaceWithManyTinyDiffs(
|
||||
fileCount: number,
|
||||
branch = "main",
|
||||
): Promise<DirtyWorkspace> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||
if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,12 @@ async function previewExplorerResize(page: Page, distance: number) {
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectCleanChangesLoaded(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
await page.reload();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
@@ -100,6 +106,7 @@ test("Explorer drag previews without rerendering panes or rewriting the shared s
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expectCleanChangesLoaded(page);
|
||||
// Verify that direct pane renders are being counted before resetting them.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { addProjectFlow } from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
chooseFooterOverflowAction,
|
||||
expectFooterLine,
|
||||
expectFooterMenusInsideViewport,
|
||||
expectFooterNeverClipped,
|
||||
expectFooterOverflowActions,
|
||||
footerOverflowMenu,
|
||||
openFooterOverflow,
|
||||
seedDockedSidebarWidth,
|
||||
watchFooterClipping,
|
||||
} from "../support/helpers/sidebar-footer-overflow";
|
||||
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
|
||||
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const TOUCH_CONTROL = 44;
|
||||
const MIN_SIDEBAR_WIDTH = 200;
|
||||
const ALL_INLINE = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
|
||||
const NARROW_TOUCH_LINE = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-footer-overflow",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
test.describe("Sidebar footer on a touch screen", () => {
|
||||
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
|
||||
|
||||
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
|
||||
await watchFooterClipping(page);
|
||||
await gotoAppShell(page);
|
||||
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
|
||||
await expectFooterNeverClipped(page);
|
||||
|
||||
await test.step("Help opens as a page of the overflow menu", async () => {
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
|
||||
await chooseFooterOverflowAction(page, "Help and support");
|
||||
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(footerOverflowMenu(page)).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Usage opens from the overflow menu", async () => {
|
||||
await openFooterOverflow(page);
|
||||
await chooseFooterOverflowAction(page, "Usage");
|
||||
await expect(footerOverflowMenu(page)).toBeHidden();
|
||||
await expect(page.getByTestId("usage-modal")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("usage-modal")).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Add project and Settings shortcuts still work", async () => {
|
||||
await page.keyboard.press("Control+o");
|
||||
await expect(addProjectFlow(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await page.keyboard.press("Control+,");
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
});
|
||||
});
|
||||
|
||||
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
|
||||
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
|
||||
|
||||
test("the overflow menu reaches the hidden actions", async ({ page }) => {
|
||||
await watchFooterClipping(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
|
||||
await expectFooterNeverClipped(page);
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer on a short, narrow phone", () => {
|
||||
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
|
||||
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
|
||||
|
||||
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostsAndReload(
|
||||
page,
|
||||
Array.from({ length: 10 }, (_, index) => ({
|
||||
serverId: `overflow-host-${index}`,
|
||||
label: `Overflow host ${index}`,
|
||||
})),
|
||||
);
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterLine(page, {
|
||||
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
|
||||
controlSize: TOUCH_CONTROL,
|
||||
});
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
await chooseFooterOverflowAction(page, "Hosts");
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
const addHost = page.getByRole("menuitem", { name: "Add host" });
|
||||
await addHost.scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
|
||||
await expect(addHost).toBeInViewport();
|
||||
await addHost.click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
expectFooterItemSetting,
|
||||
expectFooterSeparator,
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
@@ -71,112 +70,7 @@ async function footerClip(page: Page) {
|
||||
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
|
||||
}
|
||||
|
||||
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
|
||||
await page.evaluate((state) => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => state === "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, visibility);
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
|
||||
let usedPct = 31;
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
() => [
|
||||
{
|
||||
...claudeAndCodexReports()[0]!,
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(WIDE);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h"]);
|
||||
const startingUrl = page.url();
|
||||
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
|
||||
const requestsBeforeRefresh = usage.listRequests();
|
||||
|
||||
usedPct = 42;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
|
||||
await expectPinnedUsage(page, ["42% 5h"]);
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
...requestsBeforeRefresh,
|
||||
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
|
||||
]);
|
||||
|
||||
await setPageVisibility(page, "hidden");
|
||||
const requestsBeforeBackgrounding = usage.listRequests();
|
||||
usedPct = 47;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
|
||||
await expectPinnedUsage(page, ["42% 5h"]);
|
||||
await setPageVisibility(page, "visible");
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await expectPinnedUsage(page, ["47% 5h"]);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
...requestsBeforeBackgrounding,
|
||||
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
|
||||
]);
|
||||
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(usageItem(page)).toHaveCount(0);
|
||||
const requestsBeforeHiding = usage.listRequests();
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
|
||||
|
||||
await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["47% 5h"]);
|
||||
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
|
||||
usedPct = 53;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await expectPinnedUsage(page, ["53% 5h"]);
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize(WIDE);
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, "usage", false);
|
||||
await leaveSettings(page);
|
||||
await expectNoUsageItem(page);
|
||||
const requestsBeforeDisabling = usage.listRequests();
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
|
||||
});
|
||||
|
||||
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(COMPACT);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
await openSettingsHostSection(page, getServerId(), "usage");
|
||||
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveCount(0);
|
||||
const requestsBeforeWaiting = usage.listRequests();
|
||||
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
|
||||
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
|
||||
});
|
||||
|
||||
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -22,27 +22,15 @@ export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
|
||||
}
|
||||
|
||||
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
|
||||
export async function addOfflineHostsAndReload(
|
||||
page: Page,
|
||||
hosts: Array<{ serverId: string; label: string }>,
|
||||
options: { primaryLabel?: string } = {},
|
||||
): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const offlineHosts = hosts.map((host) =>
|
||||
buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso,
|
||||
}),
|
||||
);
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ hosts: added, keys, primaryLabel }) => {
|
||||
({ host, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
@@ -52,22 +40,20 @@ export async function addOfflineHostsAndReload(
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
for (const host of added) {
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
hosts: offlineHosts,
|
||||
host: offlineHost,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: options.primaryLabel,
|
||||
primaryLabel: input.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** Every footer control that can sit in the icon line, caret included. */
|
||||
const FOOTER_LINE_TEST_IDS = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-footer-overflow",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
|
||||
const GEOMETRY_TOLERANCE = 0.01;
|
||||
|
||||
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
|
||||
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
|
||||
await page.addInitScript((sidebarWidth) => {
|
||||
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
|
||||
}, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* From the first frame, record any frame in which a footer button reaches past its row, so a row
|
||||
* laid out before it measured shows up even if it lasted one frame.
|
||||
*/
|
||||
export async function watchFooterClipping(page: Page): Promise<void> {
|
||||
await page.addInitScript((testIDs) => {
|
||||
const clippedFrames: string[] = [];
|
||||
Object.assign(window, { __footerClippedFrames: clippedFrames });
|
||||
const sample = () => {
|
||||
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
if (rowRect.width === 0) continue;
|
||||
for (const testID of testIDs) {
|
||||
const button = row.querySelector(`[data-testid="${testID}"]`);
|
||||
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
|
||||
clippedFrames.push(testID);
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}, FOOTER_LINE_TEST_IDS);
|
||||
}
|
||||
|
||||
export async function expectFooterNeverClipped(page: Page): Promise<void> {
|
||||
const clipped = await page.evaluate(
|
||||
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
|
||||
);
|
||||
expect(clipped).toEqual([]);
|
||||
}
|
||||
|
||||
function footerLine(page: Page): Locator {
|
||||
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
|
||||
}
|
||||
|
||||
export function footerOverflowMenu(page: Page): Locator {
|
||||
return page.getByTestId("sidebar-footer-overflow-menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
|
||||
* line's box, so no button is cut off by the sidebar's edge.
|
||||
*/
|
||||
export async function expectFooterLine(
|
||||
page: Page,
|
||||
input: { testIDs: string[]; controlSize: number },
|
||||
): Promise<void> {
|
||||
const line = footerLine(page);
|
||||
await expect(line).toHaveCount(1);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
line
|
||||
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
|
||||
)
|
||||
.toEqual(input.testIDs);
|
||||
const { row, buttons } = await line.evaluate((element, testIDs) => {
|
||||
const rect = (node: Element) => {
|
||||
const { x, width, height } = node.getBoundingClientRect();
|
||||
return { x, width, height };
|
||||
};
|
||||
return {
|
||||
row: rect(element),
|
||||
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
|
||||
};
|
||||
}, input.testIDs);
|
||||
for (const button of buttons) {
|
||||
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
|
||||
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
|
||||
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
|
||||
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFooterOverflow(page: Page): Promise<void> {
|
||||
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
|
||||
await expect(footerOverflowMenu(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** The overflow menu lists exactly these actions, in row order. */
|
||||
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
|
||||
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
|
||||
}
|
||||
|
||||
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
|
||||
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
|
||||
}
|
||||
|
||||
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
|
||||
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize()!;
|
||||
const rows = page.getByRole("menuitem");
|
||||
await expect(rows.first()).toBeVisible();
|
||||
const boxes = await rows.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const { x, width } = element.getBoundingClientRect();
|
||||
return { x, width };
|
||||
}),
|
||||
);
|
||||
for (const box of boxes) {
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -65,6 +65,7 @@ interface BottomAnchorControllerDriver {
|
||||
pendingVerification: AttemptContext | null;
|
||||
blockedReason: BottomAnchorBlockedReason | null;
|
||||
};
|
||||
resetForAgent: () => void;
|
||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||
beginUserScroll: () => void;
|
||||
@@ -479,6 +480,17 @@ function createBottomAnchorControllerDriver(
|
||||
blockedReason,
|
||||
};
|
||||
},
|
||||
resetForAgent() {
|
||||
lastRouteRequestKey = null;
|
||||
pendingRequest = null;
|
||||
blockedReason = null;
|
||||
cancelPendingAttempt();
|
||||
stickyMeasurementRevision = 0;
|
||||
lastVerifiedStickyMeasurementRevision = 0;
|
||||
isUserScrollActive = false;
|
||||
mode = "sticky-bottom";
|
||||
input.onModeChange("sticky-bottom");
|
||||
},
|
||||
applyRouteRequest(request) {
|
||||
if (!request) {
|
||||
return;
|
||||
@@ -729,6 +741,10 @@ export function useBottomAnchorController(input: {
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.resetForAgent();
|
||||
}, [input.agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.applyRouteRequest(input.routeRequest);
|
||||
}, [input.routeRequest]);
|
||||
|
||||
@@ -129,8 +129,7 @@ function mountList(heldRowId: string) {
|
||||
}
|
||||
return position - state.scroll;
|
||||
};
|
||||
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
|
||||
return { render, scrollTo, heldRowTop, rowSize };
|
||||
return { render, scrollTo, heldRowTop };
|
||||
}
|
||||
|
||||
it("anchors a data change on a held row whose top is above the viewport", async () => {
|
||||
@@ -170,21 +169,3 @@ it("anchors a size change on a held row whose top is above the viewport", async
|
||||
|
||||
expect(list.heldRowTop()).toBe(-20);
|
||||
});
|
||||
|
||||
it("keeps measured row sizes while the list is not laid out", async () => {
|
||||
// A hidden retained tab is display: none. Its rows still render, and a row measured there
|
||||
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
|
||||
// event, so a 0 recorded while hidden would stay and the next row would overlap it.
|
||||
const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 }));
|
||||
const list = mountList("row-0");
|
||||
list.render(rows);
|
||||
await wait(150);
|
||||
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
|
||||
|
||||
host.style.display = "none";
|
||||
await wait(150);
|
||||
list.render(rows.map((row) => ({ ...row })));
|
||||
await wait(150);
|
||||
|
||||
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
|
||||
});
|
||||
|
||||
@@ -137,8 +137,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
} = props;
|
||||
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
|
||||
const listRef = useRef<LegendListRef>(null);
|
||||
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
|
||||
const [isListPlaced, setIsListPlaced] = useState(false);
|
||||
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
|
||||
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
|
||||
const isListPlaced = placedAgentId === agentId;
|
||||
const isListPlacedRef = useRef(isListPlaced);
|
||||
isListPlacedRef.current = isListPlaced;
|
||||
const streamViewportMetricsRef = useRef({
|
||||
@@ -383,6 +384,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
containerKey: "native-virtualized",
|
||||
contentHeight: 0,
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
isUserScrollActiveRef.current = false;
|
||||
clearPendingUserScrollEnd();
|
||||
disclosureHold.cancel();
|
||||
historyStartReadyRef.current = false;
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -393,7 +411,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -605,7 +623,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
|
||||
const handleListPlaced = useStableEvent(() => {
|
||||
isListPlacedRef.current = true;
|
||||
setIsListPlaced(true);
|
||||
setPlacedAgentId(agentId);
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
@@ -693,6 +711,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onItemSizeChanged={handleItemSizeChanged}
|
||||
onReady={handleListPlaced}
|
||||
dataKey={agentId}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
recycleItems={false}
|
||||
|
||||
@@ -839,6 +839,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
readingAnchor.reset();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -849,7 +853,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [evaluateHistoryStart]);
|
||||
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
@@ -1391,7 +1395,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
|
||||
return createStreamStrategy({
|
||||
render: (renderInput) => (
|
||||
<WebStreamViewport {...renderInput} isMobileBreakpoint={input.isMobileBreakpoint} />
|
||||
<WebStreamViewport
|
||||
key={renderInput.agentId}
|
||||
{...renderInput}
|
||||
isMobileBreakpoint={input.isMobileBreakpoint}
|
||||
/>
|
||||
),
|
||||
orderTailReverse: false,
|
||||
orderHeadReverse: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
]).flat();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }),
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
|
||||
);
|
||||
|
||||
expect(result.current.start).toBe(2);
|
||||
@@ -35,6 +35,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
const loadRemoteOlder = vi.fn(async () => true);
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({
|
||||
agentId: "agent-1",
|
||||
items: [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
@@ -66,7 +67,8 @@ describe("useStreamHistoryWindow", () => {
|
||||
message("a2", "assistant_message"),
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
{ initialProps: { history: items } },
|
||||
);
|
||||
|
||||
@@ -89,7 +91,8 @@ describe("useStreamHistoryWindow", () => {
|
||||
message("a2", "assistant_message"),
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
{ initialProps: { history: items } },
|
||||
);
|
||||
|
||||
@@ -105,7 +108,8 @@ describe("useStreamHistoryWindow", () => {
|
||||
configurable: true,
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
{
|
||||
initialProps: {
|
||||
history: [
|
||||
@@ -138,6 +142,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({
|
||||
agentId: "agent-1",
|
||||
items: [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
|
||||
@@ -28,17 +28,22 @@ function findEarlierHistoryWindowStart(input: {
|
||||
}
|
||||
|
||||
interface HistoryWindowBoundary {
|
||||
agentId: string;
|
||||
boundaryItemId: string | null;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
function reconcileHistoryWindow(input: {
|
||||
current: HistoryWindowBoundary;
|
||||
agentId: string;
|
||||
boundaryIndex: number;
|
||||
initialBoundaryItemId: string | null;
|
||||
hasItems: boolean;
|
||||
}): HistoryWindowBoundary {
|
||||
const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input;
|
||||
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
|
||||
if (current.agentId !== agentId) {
|
||||
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
|
||||
}
|
||||
if (!current.initialized && hasItems) {
|
||||
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
|
||||
}
|
||||
@@ -49,26 +54,28 @@ function reconcileHistoryWindow(input: {
|
||||
}
|
||||
|
||||
export function useStreamHistoryWindow(input: {
|
||||
agentId: string;
|
||||
items: StreamItem[];
|
||||
loadRemoteOlder: () => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const { items, loadRemoteOlder } = input;
|
||||
const { agentId, items, loadRemoteOlder } = input;
|
||||
const initialStart = useMemo(
|
||||
() => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }),
|
||||
[items],
|
||||
);
|
||||
const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null);
|
||||
const [window, setWindow] = useState(() => ({
|
||||
agentId,
|
||||
boundaryItemId: initialBoundaryItemId,
|
||||
initialized: items.length > 0,
|
||||
}));
|
||||
const boundaryItemId = window.boundaryItemId;
|
||||
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
|
||||
const boundaryIndex = useMemo(
|
||||
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
|
||||
[boundaryItemId, items],
|
||||
);
|
||||
let start = initialStart;
|
||||
if (window.initialized) {
|
||||
if (window.agentId === agentId && window.initialized) {
|
||||
if (window.boundaryItemId === null) {
|
||||
start = 0;
|
||||
} else if (boundaryIndex >= 0) {
|
||||
@@ -80,12 +87,13 @@ export function useStreamHistoryWindow(input: {
|
||||
setWindow((current) =>
|
||||
reconcileHistoryWindow({
|
||||
current,
|
||||
agentId,
|
||||
boundaryIndex,
|
||||
initialBoundaryItemId,
|
||||
hasItems: items.length > 0,
|
||||
}),
|
||||
);
|
||||
}, [boundaryIndex, initialBoundaryItemId, items.length]);
|
||||
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
|
||||
|
||||
const revealLoadedHistory = useCallback(
|
||||
(itemId?: string): boolean => {
|
||||
@@ -98,12 +106,13 @@ export function useStreamHistoryWindow(input: {
|
||||
return false;
|
||||
}
|
||||
setWindow({
|
||||
agentId,
|
||||
boundaryItemId: items[nextStart]?.id ?? null,
|
||||
initialized: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[items, start],
|
||||
[agentId, items, start],
|
||||
);
|
||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||
return revealLoadedHistory() || (await loadRemoteOlder());
|
||||
|
||||
@@ -430,6 +430,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
loadOlder: historyPagination.onLoadOlder,
|
||||
}
|
||||
: agentHistoryPagination;
|
||||
useEffect(() => {
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
const openInlineFile = useStableEvent(
|
||||
@@ -472,7 +476,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
return () => inlinePathOpener.cancel();
|
||||
}, [inlinePathOpener, isActive]);
|
||||
}, [inlinePathOpener, isActive, agentId]);
|
||||
const handleInlinePathPress = useStableEvent(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (isActive) void inlinePathOpener.open(target, disposition);
|
||||
@@ -547,6 +551,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
revealLoadedHistory,
|
||||
loadOlder,
|
||||
} = useStreamHistoryWindow({
|
||||
agentId,
|
||||
items: presentation.tail,
|
||||
loadRemoteOlder,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
} from "react-native";
|
||||
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
@@ -41,17 +40,6 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarFooterOverflowMenu } from "@/components/sidebar/sidebar-footer-overflow-menu";
|
||||
import {
|
||||
resolveSidebarFooterLayout,
|
||||
SIDEBAR_FOOTER_ACTIONS,
|
||||
type SidebarFooterAction,
|
||||
} from "@/components/sidebar-footer-layout";
|
||||
import {
|
||||
controlSizingForLayout,
|
||||
mapLayout,
|
||||
TOOLBAR_CONTROL_SIZES,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
@@ -67,7 +55,6 @@ import {
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { density } from "@/styles/density";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
@@ -92,9 +79,6 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
/** The footer icon line's inset; the overflow layout counts it as space the buttons can't use. */
|
||||
const FOOTER_ROW_PADDING = SPACING[2];
|
||||
|
||||
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
|
||||
|
||||
interface SidebarSharedProps {
|
||||
@@ -426,72 +410,35 @@ function SidebarFooter({
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const [rowWidth, setRowWidth] = useState(0);
|
||||
const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setRowWidth(event.nativeEvent.layout.width);
|
||||
}, []);
|
||||
const { inline, overflow } = useMemo(
|
||||
() =>
|
||||
resolveSidebarFooterLayout({
|
||||
rowWidth,
|
||||
controlSize: TOOLBAR_CONTROL_SIZES[controlSizingForLayout(isCompactLayout)],
|
||||
rowPadding: FOOTER_ROW_PADDING,
|
||||
actions: SIDEBAR_FOOTER_ACTIONS,
|
||||
}),
|
||||
[rowWidth, isCompactLayout],
|
||||
);
|
||||
const isInline = (action: SidebarFooterAction) => inline.includes(action);
|
||||
|
||||
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. Actions that
|
||||
// don't fit move behind a caret before Settings.
|
||||
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
|
||||
return (
|
||||
<UsageSidebarRoot>
|
||||
<View style={styles.footerContainer} testID="sidebar-footer">
|
||||
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
|
||||
<View
|
||||
style={styles.sidebarFooter}
|
||||
onLayout={handleRowLayout}
|
||||
testID="sidebar-footer-bottom-line"
|
||||
>
|
||||
{isInline("add-project") ? (
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
/>
|
||||
) : null}
|
||||
{isInline("usage") ? <SidebarUsageIcon label={labels.usage} /> : null}
|
||||
{isInline("hosts") ? (
|
||||
<SidebarHostPicker
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
/>
|
||||
<SidebarUsageIcon label={labels.usage} />
|
||||
<SidebarHostPicker
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<View style={styles.footerSpacer} />
|
||||
{isInline("help") ? <SidebarHelpMenu /> : null}
|
||||
{overflow.length > 0 ? (
|
||||
<SidebarFooterOverflowMenu
|
||||
actions={overflow}
|
||||
labels={labels}
|
||||
onAddProject={handleOpenProject}
|
||||
onSettings={handleSettings}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
) : null}
|
||||
{isInline("settings") ? (
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
/>
|
||||
) : null}
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</UsageSidebarRoot>
|
||||
@@ -990,12 +937,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
// Buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
// As tall as its buttons from the first frame, which places none until the row has measured.
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2),
|
||||
paddingHorizontal: FOOTER_ROW_PADDING,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
},
|
||||
// Pushes Help and Settings to the end of the icon line.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveSidebarFooterLayout,
|
||||
SIDEBAR_FOOTER_ACTIONS,
|
||||
} from "@/components/sidebar-footer-layout";
|
||||
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
|
||||
const TOUCH = 44;
|
||||
const POINTER = 28;
|
||||
const ROW_PADDING = 8;
|
||||
|
||||
function layout(rowWidth: number, controlSize: number) {
|
||||
return resolveSidebarFooterLayout({
|
||||
rowWidth,
|
||||
controlSize,
|
||||
rowPadding: ROW_PADDING,
|
||||
actions: SIDEBAR_FOOTER_ACTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
describe("sidebar footer layout", () => {
|
||||
it("keeps every action inline at the default width with either sizing", () => {
|
||||
for (const controlSize of [TOUCH, POINTER]) {
|
||||
expect(layout(DEFAULT_SIDEBAR_WIDTH, controlSize)).toEqual({
|
||||
inline: ["add-project", "usage", "hosts", "help", "settings"],
|
||||
overflow: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every action inline at the minimum width with pointer sizing", () => {
|
||||
expect(layout(MIN_SIDEBAR_WIDTH, POINTER).overflow).toEqual([]);
|
||||
});
|
||||
|
||||
// The iPhone Duo report: the folded sidebar column is about the minimum width, and touch
|
||||
// sizing makes five 44pt buttons plus padding 236pt wide.
|
||||
it("moves Help and Usage behind the caret at the minimum width with touch sizing", () => {
|
||||
expect(layout(MIN_SIDEBAR_WIDTH, TOUCH)).toEqual({
|
||||
inline: ["add-project", "hosts", "settings"],
|
||||
overflow: ["usage", "help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Settings and Add project inline longest", () => {
|
||||
// Three slots: two actions and the caret.
|
||||
expect(layout(3 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
|
||||
inline: ["add-project", "settings"],
|
||||
overflow: ["usage", "hosts", "help"],
|
||||
});
|
||||
// Two slots: Settings and the caret.
|
||||
expect(layout(2 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
|
||||
inline: ["settings"],
|
||||
overflow: ["add-project", "usage", "hosts", "help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("puts everything behind the caret when only the caret fits", () => {
|
||||
expect(layout(TOUCH + 2 * ROW_PADDING - 10, TOUCH)).toEqual({
|
||||
inline: [],
|
||||
overflow: ["add-project", "usage", "hosts", "help", "settings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("fits all five actions exactly at their combined width", () => {
|
||||
const exact = 5 * TOUCH + 2 * ROW_PADDING;
|
||||
expect(layout(exact, TOUCH).overflow).toEqual([]);
|
||||
// Browser layout rounds a little below the exact width.
|
||||
expect(layout(exact - 0.4, TOUCH).overflow).toEqual([]);
|
||||
expect(layout(exact - 1, TOUCH).overflow).toEqual(["usage", "help"]);
|
||||
});
|
||||
|
||||
it("only places the actions it is given", () => {
|
||||
expect(
|
||||
resolveSidebarFooterLayout({
|
||||
rowWidth: 3 * TOUCH + 2 * ROW_PADDING,
|
||||
controlSize: TOUCH,
|
||||
rowPadding: ROW_PADDING,
|
||||
actions: ["add-project", "hosts", "settings"],
|
||||
}),
|
||||
).toEqual({ inline: ["add-project", "hosts", "settings"], overflow: [] });
|
||||
});
|
||||
|
||||
// Laying out against a guessed width would paint a clipped row for a frame on narrow sidebars.
|
||||
it("places nothing before the row has been measured", () => {
|
||||
for (const controlSize of [TOUCH, POINTER]) {
|
||||
expect(layout(0, controlSize)).toEqual({ inline: [], overflow: [] });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
/** The sidebar footer's icon line, in row order. */
|
||||
export const SIDEBAR_FOOTER_ACTIONS = [
|
||||
"add-project",
|
||||
"usage",
|
||||
"hosts",
|
||||
"help",
|
||||
"settings",
|
||||
] as const;
|
||||
|
||||
export type SidebarFooterAction = (typeof SIDEBAR_FOOTER_ACTIONS)[number];
|
||||
|
||||
/** The order actions keep their place in a row too narrow for all of them. */
|
||||
const KEEP_ORDER: readonly SidebarFooterAction[] = [
|
||||
"settings",
|
||||
"add-project",
|
||||
"hosts",
|
||||
"usage",
|
||||
"help",
|
||||
];
|
||||
|
||||
/** Measured widths land a hair under the exact sum of their parts. */
|
||||
const MEASUREMENT_TOLERANCE = 0.5;
|
||||
|
||||
export interface SidebarFooterLayout {
|
||||
/** Actions drawn in the row, in row order. */
|
||||
inline: SidebarFooterAction[];
|
||||
/** Actions behind the overflow caret, in row order. Empty when everything fits. */
|
||||
overflow: SidebarFooterAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the footer's actions between the row and the overflow menu. Buttons keep their full size;
|
||||
* when they don't all fit, the caret takes one slot and the actions that lose their place move
|
||||
* behind it, Help first and Settings last. Nothing is placed until the row has a measured width.
|
||||
*/
|
||||
export function resolveSidebarFooterLayout(input: {
|
||||
rowWidth: number;
|
||||
controlSize: number;
|
||||
rowPadding: number;
|
||||
actions: readonly SidebarFooterAction[];
|
||||
}): SidebarFooterLayout {
|
||||
const { rowWidth, controlSize, rowPadding, actions } = input;
|
||||
// An unmeasured row stays empty: any guess would paint a clipped row for a frame on a narrow
|
||||
// sidebar before the real width arrives.
|
||||
if (rowWidth <= 0) {
|
||||
return { inline: [], overflow: [] };
|
||||
}
|
||||
const slots = Math.floor(
|
||||
(rowWidth - rowPadding * 2 + MEASUREMENT_TOLERANCE) / Math.max(controlSize, 1),
|
||||
);
|
||||
if (slots >= actions.length) {
|
||||
return { inline: [...actions], overflow: [] };
|
||||
}
|
||||
const inlineCount = Math.max(slots - 1, 0);
|
||||
const kept = new Set(
|
||||
KEEP_ORDER.filter((action) => actions.includes(action)).slice(0, inlineCount),
|
||||
);
|
||||
return {
|
||||
inline: actions.filter((action) => kept.has(action)),
|
||||
overflow: actions.filter((action) => !kept.has(action)),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { density } from "@/styles/density";
|
||||
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
|
||||
|
||||
/**
|
||||
* A footer icon that opens a menu. Sized and drawn like the footer's other icon buttons; the
|
||||
* tooltip stays out of the way while the menu is open.
|
||||
*/
|
||||
export function SidebarFooterMenuTrigger({
|
||||
icon,
|
||||
label,
|
||||
menuOpen,
|
||||
testID,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
menuOpen: boolean;
|
||||
testID: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!menuOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
style={styles.trigger}
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
{({ hovered, pressed, open }) => (
|
||||
<>
|
||||
<SidebarFooterIconHighlight visible={pressed || open} />
|
||||
<DensityIcon
|
||||
icon={icon}
|
||||
size="md"
|
||||
color={hovered ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: density.iconButton,
|
||||
height: density.iconButton,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}));
|
||||
@@ -1,245 +0,0 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronUp,
|
||||
CircleGauge,
|
||||
CircleHelp,
|
||||
FolderPlus,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
||||
import type { SidebarFooterAction } from "@/components/sidebar-footer-layout";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSubTrigger,
|
||||
type MenuPageDefinition,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import { useOpenSidebarUsage } from "@/usage";
|
||||
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
|
||||
import { SidebarHelpMenuItems } from "./sidebar-help-menu";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const ThemedCircleGauge = withUnistyles(CircleGauge);
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedCircleHelp = withUnistyles(CircleHelp);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const addProjectLeading = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const usageLeading = <ThemedCircleGauge size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const hostsLeading = <ThemedServer size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const helpLeading = <ThemedCircleHelp size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const settingsLeading = <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const addHostLeading = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
|
||||
const HOSTS_PAGE_ID = "hosts";
|
||||
const HELP_PAGE_ID = "help";
|
||||
|
||||
export interface SidebarFooterOverflowMenuProps {
|
||||
/** The footer actions that did not fit in the row, in row order. */
|
||||
actions: readonly SidebarFooterAction[];
|
||||
labels: { addProject: string; hosts: string; settings: string; usage: string };
|
||||
onAddProject: () => void;
|
||||
onSettings: () => void;
|
||||
onAddHost: () => void;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The caret at the end of a footer row too narrow for every action. It lists the actions that
|
||||
* lost their place, with the same test IDs they carry in the row; Hosts and Help, menus of their
|
||||
* own in the row, open as pages of this menu.
|
||||
*/
|
||||
export function SidebarFooterOverflowMenu({
|
||||
actions,
|
||||
labels,
|
||||
onAddProject,
|
||||
onSettings,
|
||||
onAddHost,
|
||||
onOpenHostSettings,
|
||||
}: SidebarFooterOverflowMenuProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
const helpLabel = t("sidebar.help.trigger");
|
||||
const addProjectTrailing = useMemo(
|
||||
() => (newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null),
|
||||
[newAgentKeys],
|
||||
);
|
||||
const settingsTrailing = useMemo(
|
||||
() => (settingsKeys ? <Shortcut chord={settingsKeys} /> : null),
|
||||
[settingsKeys],
|
||||
);
|
||||
|
||||
const pages = useMemo((): MenuPageDefinition[] => {
|
||||
const result: MenuPageDefinition[] = [];
|
||||
if (actions.includes("hosts")) {
|
||||
result.push({
|
||||
id: HOSTS_PAGE_ID,
|
||||
title: labels.hosts,
|
||||
content: <HostsPage onAddHost={onAddHost} onOpenHostSettings={onOpenHostSettings} />,
|
||||
});
|
||||
}
|
||||
if (actions.includes("help")) {
|
||||
result.push({ id: HELP_PAGE_ID, title: helpLabel, content: <SidebarHelpMenuItems /> });
|
||||
}
|
||||
return result;
|
||||
}, [actions, helpLabel, labels.hosts, onAddHost, onOpenHostSettings]);
|
||||
|
||||
function renderAction(action: SidebarFooterAction): ReactElement {
|
||||
switch (action) {
|
||||
case "add-project":
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
testID="sidebar-add-project"
|
||||
leading={addProjectLeading}
|
||||
trailing={addProjectTrailing}
|
||||
onSelect={onAddProject}
|
||||
>
|
||||
{labels.addProject}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
case "usage":
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
testID="sidebar-usage-icon"
|
||||
leading={usageLeading}
|
||||
onSelect={openUsage}
|
||||
>
|
||||
{labels.usage}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
case "hosts":
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
key={action}
|
||||
id={HOSTS_PAGE_ID}
|
||||
testID="sidebar-hosts-trigger"
|
||||
leading={hostsLeading}
|
||||
>
|
||||
{labels.hosts}
|
||||
</DropdownMenuSubTrigger>
|
||||
);
|
||||
case "help":
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
key={action}
|
||||
id={HELP_PAGE_ID}
|
||||
testID="sidebar-help"
|
||||
leading={helpLeading}
|
||||
>
|
||||
{helpLabel}
|
||||
</DropdownMenuSubTrigger>
|
||||
);
|
||||
case "settings":
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
testID="sidebar-settings"
|
||||
leading={settingsLeading}
|
||||
trailing={settingsTrailing}
|
||||
onSelect={onSettings}
|
||||
>
|
||||
{labels.settings}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<SidebarFooterMenuTrigger
|
||||
icon={ChevronUp}
|
||||
label={t("sidebar.actions.moreActions")}
|
||||
menuOpen={open}
|
||||
testID="sidebar-footer-overflow"
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
offset={8}
|
||||
width={240}
|
||||
pages={pages}
|
||||
scrollable
|
||||
testID="sidebar-footer-overflow-menu"
|
||||
>
|
||||
{actions.map(renderAction)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/** The host picker's choices as a menu page: each host opens its settings, then Add host. */
|
||||
function HostsPage({
|
||||
onAddHost,
|
||||
onOpenHostSettings,
|
||||
}: {
|
||||
onAddHost: () => void;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const orderedHosts = useMemo(
|
||||
() => orderHostsLocalFirst(hosts, localServerId),
|
||||
[hosts, localServerId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{orderedHosts.map((host) => (
|
||||
<HostRow
|
||||
key={host.serverId}
|
||||
serverId={host.serverId}
|
||||
label={host.label}
|
||||
onOpenHostSettings={onOpenHostSettings}
|
||||
/>
|
||||
))}
|
||||
{orderedHosts.length > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem testID="sidebar-host-add" leading={addHostLeading} onSelect={onAddHost}>
|
||||
{t("settings.addHost")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HostRow({
|
||||
serverId,
|
||||
label,
|
||||
onOpenHostSettings,
|
||||
}: {
|
||||
serverId: string;
|
||||
label: string;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(
|
||||
() => onOpenHostSettings(serverId),
|
||||
[onOpenHostSettings, serverId],
|
||||
);
|
||||
const leading = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-host-row-${serverId}`}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -12,13 +12,17 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { density } from "@/styles/density";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
@@ -73,32 +77,12 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The Help menu's trigger, and the menu it opens. */
|
||||
export function SidebarHelpMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<SidebarFooterMenuTrigger
|
||||
icon={CircleHelp}
|
||||
label={t("sidebar.help.trigger")}
|
||||
menuOpen={open}
|
||||
testID="sidebar-help"
|
||||
/>
|
||||
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
|
||||
<SidebarHelpMenuItems />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Help menu's rows, shared by the Help menu and the footer's overflow page for it. */
|
||||
export function SidebarHelpMenuItems() {
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
|
||||
const [open, setOpen] = useState(false);
|
||||
const version = formatVersionWithPrefix(resolveAppVersion());
|
||||
const hosts = useHosts();
|
||||
|
||||
@@ -115,65 +99,105 @@ export function SidebarHelpMenuItems() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
|
||||
{shortcutsAvailable ? (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
style={styles.trigger}
|
||||
testID="sidebar-help"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("sidebar.help.trigger")}
|
||||
>
|
||||
{({ hovered, pressed, open: menuOpen }) => (
|
||||
<>
|
||||
<SidebarFooterIconHighlight visible={pressed || menuOpen} />
|
||||
<DensityIcon
|
||||
icon={CircleHelp}
|
||||
size="md"
|
||||
color={hovered ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("sidebar.help.trigger")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
|
||||
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
|
||||
{shortcutsAvailable ? (
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-shortcuts"
|
||||
leading={shortcutsLeadingIcon}
|
||||
onSelect={openKeyboardShortcuts}
|
||||
>
|
||||
{t("sidebar.help.shortcuts")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-shortcuts"
|
||||
leading={shortcutsLeadingIcon}
|
||||
onSelect={openKeyboardShortcuts}
|
||||
testID="sidebar-help-changelog"
|
||||
leading={changelogLeadingIcon}
|
||||
onSelect={openChangelog}
|
||||
>
|
||||
{t("sidebar.help.shortcuts")}
|
||||
{t("sidebar.help.whatsNew")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-changelog"
|
||||
leading={changelogLeadingIcon}
|
||||
onSelect={openChangelog}
|
||||
>
|
||||
{t("sidebar.help.whatsNew")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-diagnostics"
|
||||
leading={diagnosticLeadingIcon}
|
||||
onSelect={openAppDiagnostic}
|
||||
>
|
||||
{t("sidebar.help.diagnostics")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-discord"
|
||||
leading={discordLeadingIcon}
|
||||
onSelect={openDiscord}
|
||||
>
|
||||
{t("sidebar.help.discord")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-github"
|
||||
leading={githubLeadingIcon}
|
||||
onSelect={openGitHubIssue}
|
||||
>
|
||||
{t("sidebar.help.github")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.versionList}>
|
||||
<DropdownMenuHint
|
||||
style={styles.versionHint}
|
||||
trailing={version}
|
||||
testID="sidebar-help-version"
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-diagnostics"
|
||||
leading={diagnosticLeadingIcon}
|
||||
onSelect={openAppDiagnostic}
|
||||
>
|
||||
{t("sidebar.help.appName")}
|
||||
</DropdownMenuHint>
|
||||
{hosts.map((host) => (
|
||||
<HostVersionHint key={host.serverId} host={host} />
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
{t("sidebar.help.diagnostics")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-discord"
|
||||
leading={discordLeadingIcon}
|
||||
onSelect={openDiscord}
|
||||
>
|
||||
{t("sidebar.help.discord")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-github"
|
||||
leading={githubLeadingIcon}
|
||||
onSelect={openGitHubIssue}
|
||||
>
|
||||
{t("sidebar.help.github")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.versionList}>
|
||||
<DropdownMenuHint
|
||||
style={styles.versionHint}
|
||||
trailing={version}
|
||||
testID="sidebar-help-version"
|
||||
>
|
||||
{t("sidebar.help.appName")}
|
||||
</DropdownMenuHint>
|
||||
{hosts.map((host) => (
|
||||
<HostVersionHint key={host.serverId} host={host} />
|
||||
))}
|
||||
</View>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: density.iconButton,
|
||||
height: density.iconButton,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
versionList: {
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor";
|
||||
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
|
||||
|
||||
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
|
||||
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
|
||||
@@ -97,26 +97,3 @@ describe("getTransformOrigin", () => {
|
||||
expect(getTransformOrigin(placement, alignment)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitSurfaceWidth", () => {
|
||||
it("leaves a surface that fits the display alone, capped at the display", () => {
|
||||
expect(fitSurfaceWidth({ width: 240, minWidth: 180 }, 1000)).toEqual({
|
||||
width: 240,
|
||||
minWidth: 180,
|
||||
maxWidth: 984,
|
||||
});
|
||||
});
|
||||
|
||||
it("shrinks a surface wider than a narrow display to the space inside its edge padding", () => {
|
||||
expect(fitSurfaceWidth({ width: 240, minWidth: 180, maxWidth: 320 }, 220)).toEqual({
|
||||
width: 204,
|
||||
minWidth: 180,
|
||||
maxWidth: 204,
|
||||
});
|
||||
expect(fitSurfaceWidth({ minWidth: 180 }, 150)).toEqual({
|
||||
width: undefined,
|
||||
minWidth: 134,
|
||||
maxWidth: 134,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,27 +120,6 @@ function clampToDisplayArea(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SurfaceWidth {
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps a surface's requested width to the display. Position clamping can move a surface but not
|
||||
* shrink it, so a menu wider than a narrow screen would otherwise hang off the far edge.
|
||||
*/
|
||||
export function fitSurfaceWidth(requested: SurfaceWidth, displayWidth: number): SurfaceWidth {
|
||||
const available = Math.max(displayWidth - EDGE_PADDING * 2, 0);
|
||||
const cap = (value: number | undefined) =>
|
||||
typeof value === "number" ? Math.min(value, available) : undefined;
|
||||
return {
|
||||
width: cap(requested.width),
|
||||
minWidth: cap(requested.minWidth),
|
||||
maxWidth: cap(requested.maxWidth) ?? available,
|
||||
};
|
||||
}
|
||||
|
||||
export function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
|
||||
@@ -12,19 +12,8 @@ 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";
|
||||
@@ -101,11 +90,6 @@ describe("control sizing gate", () => {
|
||||
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
|
||||
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
|
||||
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
|
||||
// A filled toolbar control paints a 36pt disc inside its 44pt touch frame, and fills its
|
||||
// 28pt pointer frame exactly, so desktop is unchanged. Its glyph is the `md` glyph.
|
||||
expect(TOOLBAR_DISC_SIZES).toEqual({ touch: 36, pointer: TOOLBAR_CONTROL_SIZES.pointer });
|
||||
expect(TOOLBAR_DISC_SIZE).toEqual({ xs: 36, md: 28 });
|
||||
expect(iconSizeFor("md")).toEqual({ touch: 20, pointer: 16 });
|
||||
for (const size of ["sm", "md"] as const) {
|
||||
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
|
||||
controlHeightForLayout(size, false),
|
||||
@@ -118,62 +102,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,12 @@ 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.
|
||||
*/
|
||||
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 +104,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)];
|
||||
|
||||
@@ -29,7 +29,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import {
|
||||
computePosition,
|
||||
fitSurfaceWidth,
|
||||
getTransformOrigin,
|
||||
measureElement,
|
||||
type Alignment,
|
||||
@@ -284,7 +283,11 @@ export function AnchoredSurface({
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const resolvedWidthStyle: ViewStyle = fullWidth
|
||||
? { width: screenWidth - horizontalPadding * 2 }
|
||||
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
|
||||
: {
|
||||
...(typeof width === "number" ? { width } : null),
|
||||
...(typeof minWidth === "number" ? { minWidth } : null),
|
||||
...(typeof maxWidth === "number" ? { maxWidth } : null),
|
||||
};
|
||||
return [
|
||||
resolvedWidthStyle,
|
||||
{
|
||||
|
||||
@@ -1,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,
|
||||
|
||||
@@ -60,6 +60,17 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
|
||||
expect(result.current.isSubmitting).toBe(true);
|
||||
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
|
||||
expect(result.current.submittedStreamItems).toEqual([
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "msg-prepared",
|
||||
clientMessageId: "msg-prepared",
|
||||
text: "build this",
|
||||
timestamp: attempt.timestamp,
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
},
|
||||
]);
|
||||
expect(createRequest).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -8,8 +8,15 @@ import {
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { createUserMessage, type UserMessageImageAttachment } from "@/types/stream";
|
||||
import {
|
||||
createUserMessage,
|
||||
type StreamItem,
|
||||
type UserMessageImageAttachment,
|
||||
} from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
interface CreateAttempt {
|
||||
clientMessageId: string;
|
||||
@@ -146,6 +153,38 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||
const isSubmitting = machine.tag === "creating";
|
||||
|
||||
const submittedStreamItems = useMemo<StreamItem[]>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
if (
|
||||
!machine.attempt.text &&
|
||||
(!machine.attempt.images || machine.attempt.images.length === 0) &&
|
||||
(!machine.attempt.attachments || machine.attempt.attachments.length === 0)
|
||||
) {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
return [
|
||||
createUserMessage({
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
text: machine.attempt.text,
|
||||
timestamp: machine.attempt.timestamp,
|
||||
images: machine.attempt.images,
|
||||
attachments: machine.attempt.attachments,
|
||||
}),
|
||||
];
|
||||
}, [machine]);
|
||||
const pendingMessageSubmissions = useMemo<readonly PendingMessageSubmission[]>(() => {
|
||||
if (machine.tag !== "creating") return [];
|
||||
return [
|
||||
{
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
},
|
||||
];
|
||||
}, [machine]);
|
||||
|
||||
const draftAgent = machine.tag === "creating" ? machine.draftAgent : null;
|
||||
const startCreateAttempt = useCallback(
|
||||
(attempt: CreateAttempt) => {
|
||||
@@ -326,6 +365,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
machine,
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { DRAFT_CAPABILITIES, resolveDraftTimeline } from "./timeline";
|
||||
|
||||
const image: UserMessageImageAttachment = {
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "image-key",
|
||||
createdAt: 123,
|
||||
};
|
||||
const attachment = {
|
||||
type: "review",
|
||||
cwd: "/repo",
|
||||
summary: "Review",
|
||||
} as unknown as AgentAttachment;
|
||||
|
||||
function pendingAttempt(overrides: Partial<PendingCreateAttempt> = {}): PendingCreateAttempt {
|
||||
return {
|
||||
draftId: "draft-1",
|
||||
serverId: "server-1",
|
||||
agentId: null,
|
||||
clientMessageId: "draft-1:initial-message",
|
||||
text: "build this",
|
||||
timestamp: Date.parse("2026-05-25T00:00:00.000Z"),
|
||||
lifecycle: "active",
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDraftTimeline", () => {
|
||||
it("shows the sent prompt pending with the turn working", () => {
|
||||
const timeline = resolveDraftTimeline({
|
||||
pending: pendingAttempt(),
|
||||
serverId: "server-1",
|
||||
tabId: "tab-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(timeline?.agent).toEqual({
|
||||
serverId: "server-1",
|
||||
id: "tab-1",
|
||||
status: "running",
|
||||
cwd: "/repo",
|
||||
capabilities: DRAFT_CAPABILITIES,
|
||||
});
|
||||
expect(timeline?.streamItems).toEqual([
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "draft-1:initial-message",
|
||||
clientMessageId: "draft-1:initial-message",
|
||||
text: "build this",
|
||||
timestamp: new Date("2026-05-25T00:00:00.000Z"),
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
},
|
||||
]);
|
||||
expect(timeline?.pendingMessageSubmissions).toEqual([
|
||||
{ clientMessageId: "draft-1:initial-message" },
|
||||
]);
|
||||
expect(timeline?.turnPresentation.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the prompt once the agent is created, until the tab retargets", () => {
|
||||
const timeline = resolveDraftTimeline({
|
||||
pending: pendingAttempt({ lifecycle: "sent", agentId: "agent-1" }),
|
||||
serverId: "server-1",
|
||||
tabId: "tab-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(timeline?.streamItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows only the working turn for a prompt without content", () => {
|
||||
const timeline = resolveDraftTimeline({
|
||||
pending: pendingAttempt({ text: "", images: undefined, attachments: undefined }),
|
||||
serverId: "server-1",
|
||||
tabId: "tab-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(timeline?.streamItems).toEqual([]);
|
||||
expect(timeline?.turnPresentation.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("shows no timeline before a prompt is sent, after it fails, or on another host", () => {
|
||||
const base = { serverId: "server-1", tabId: "tab-1", cwd: "/repo" };
|
||||
|
||||
expect(resolveDraftTimeline({ ...base, pending: undefined })).toBeNull();
|
||||
expect(
|
||||
resolveDraftTimeline({ ...base, pending: pendingAttempt({ lifecycle: "abandoned" }) }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDraftTimeline({ ...base, pending: pendingAttempt({ serverId: "server-2" }) }),
|
||||
).toBeNull();
|
||||
expect(resolveDraftTimeline({ ...base, cwd: null, pending: pendingAttempt() })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { type PendingCreateAttempt, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
resolveTurnPresentation,
|
||||
TURN_LIVENESS_IDLE,
|
||||
type TurnPresentation,
|
||||
} from "@/timeline/turn-liveness";
|
||||
import { createUserMessage, type StreamItem } from "@/types/stream";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
import { resolveDraftWorkingDirectory } from "@/composer/draft/workspace-tab-core";
|
||||
|
||||
export const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
};
|
||||
|
||||
export interface DraftConversation {
|
||||
tabId: string;
|
||||
workspaceId: string;
|
||||
draftId: string;
|
||||
setup?: WorkspaceDraftTabSetup;
|
||||
}
|
||||
|
||||
/** The timeline a draft shows while its first prompt creates the agent. */
|
||||
export interface DraftTimeline {
|
||||
agent: AgentScreenAgent;
|
||||
streamItems: StreamItem[];
|
||||
pendingMessageSubmissions: readonly PendingMessageSubmission[];
|
||||
turnPresentation: TurnPresentation;
|
||||
}
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
/**
|
||||
* The sent prompt, pending, with the turn working. The created agent's timeline carries the same
|
||||
* row (same client message id), so the handoff only changes who owns it.
|
||||
*/
|
||||
export function resolveDraftTimeline(input: {
|
||||
pending: PendingCreateAttempt | undefined;
|
||||
serverId: string;
|
||||
tabId: string;
|
||||
cwd: string | null;
|
||||
}): DraftTimeline | null {
|
||||
const { pending, serverId, tabId, cwd } = input;
|
||||
if (!pending || pending.serverId !== serverId || pending.lifecycle === "abandoned" || !cwd) {
|
||||
return null;
|
||||
}
|
||||
const hasContent =
|
||||
pending.text.length > 0 ||
|
||||
(pending.images?.length ?? 0) > 0 ||
|
||||
(pending.attachments?.length ?? 0) > 0;
|
||||
return {
|
||||
agent: { serverId, id: tabId, status: "running", cwd, capabilities: DRAFT_CAPABILITIES },
|
||||
streamItems: hasContent
|
||||
? [
|
||||
createUserMessage({
|
||||
clientMessageId: pending.clientMessageId,
|
||||
text: pending.text,
|
||||
timestamp: new Date(pending.timestamp),
|
||||
images: pending.images,
|
||||
attachments: pending.attachments,
|
||||
}),
|
||||
]
|
||||
: EMPTY_STREAM_ITEMS,
|
||||
pendingMessageSubmissions: [{ clientMessageId: pending.clientMessageId }],
|
||||
turnPresentation: resolveTurnPresentation(TURN_LIVENESS_IDLE, true),
|
||||
};
|
||||
}
|
||||
|
||||
export function useDraftTimeline(input: {
|
||||
serverId: string;
|
||||
draft: DraftConversation | null;
|
||||
}): DraftTimeline | null {
|
||||
const { serverId, draft } = input;
|
||||
const pending = useCreateFlowStore((state) =>
|
||||
draft ? state.pendingByDraftId[draft.draftId] : undefined,
|
||||
);
|
||||
const workspaceDirectory = useWorkspaceFields(
|
||||
serverId,
|
||||
draft?.workspaceId ?? null,
|
||||
(workspace) => workspace.workspaceDirectory,
|
||||
);
|
||||
const cwd = draft
|
||||
? resolveDraftWorkingDirectory({
|
||||
workspaceDirectory: workspaceDirectory || null,
|
||||
initialSetup: draft.setup ?? null,
|
||||
})
|
||||
: null;
|
||||
const tabId = draft?.tabId ?? "";
|
||||
return useMemo(
|
||||
() => resolveDraftTimeline({ pending, serverId, tabId, cwd }),
|
||||
[cwd, pending, serverId, tabId],
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
export interface WorkspaceDraftAutoSubmitConfig {
|
||||
provider: string;
|
||||
@@ -51,13 +50,3 @@ export function validateDraftSubmission(input: {
|
||||
});
|
||||
return readiness.ok ? null : (readiness.reason ?? null);
|
||||
}
|
||||
|
||||
export function resolveDraftWorkingDirectory(input: {
|
||||
workspaceDirectory: string | null;
|
||||
initialSetup: WorkspaceDraftTabSetup | null;
|
||||
}): string | null {
|
||||
if (input.initialSetup) {
|
||||
return input.initialSetup.cwd;
|
||||
}
|
||||
return input.workspaceDirectory;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComposerDock } from "@/composer/dock";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { type ComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
|
||||
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
||||
import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
|
||||
import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
@@ -21,13 +25,13 @@ import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import {
|
||||
resolveDraftWorkingDirectory,
|
||||
shouldAllowEmptyDraftText,
|
||||
validateDraftSubmission,
|
||||
} from "@/composer/draft/workspace-tab-core";
|
||||
import { DRAFT_CAPABILITIES } from "@/composer/draft/timeline";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
@@ -46,6 +50,16 @@ import {
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
};
|
||||
|
||||
interface AutoSubmitConfig {
|
||||
provider: string;
|
||||
modeId: string | null;
|
||||
@@ -246,15 +260,25 @@ function buildDraftInitialValues(input: {
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceDraftComposerProps {
|
||||
function resolveDraftWorkingDirectory(input: {
|
||||
workspaceDirectory: string | null;
|
||||
initialSetup: WorkspaceDraftTabSetup | null;
|
||||
}): string | null {
|
||||
if (input.initialSetup) {
|
||||
return input.initialSetup.cwd;
|
||||
}
|
||||
return input.workspaceDirectory;
|
||||
}
|
||||
|
||||
interface WorkspaceDraftAgentTabProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
draftId: string;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
isPaneFocused: boolean;
|
||||
editingSession: ComposerEditingSession;
|
||||
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
||||
onOpenImportSheet?: () => void;
|
||||
}
|
||||
|
||||
@@ -268,21 +292,17 @@ function resolveImportPillPress(
|
||||
return onOpenImportSheet ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the agent from the draft's first prompt. The conversation panel shows that prompt in
|
||||
* the timeline from the create-flow store while the agent is created.
|
||||
*/
|
||||
export function WorkspaceDraftComposer({
|
||||
export function WorkspaceDraftAgentTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId,
|
||||
draftId,
|
||||
initialSetup = undefined,
|
||||
isPaneFocused,
|
||||
editingSession,
|
||||
onCreated,
|
||||
onOpenWorkspaceFile,
|
||||
onOpenImportSheet,
|
||||
}: WorkspaceDraftComposerProps) {
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -367,6 +387,7 @@ export function WorkspaceDraftComposer({
|
||||
}, [pendingCreateAttempt]);
|
||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const editingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(editingSession);
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_CONTENT_WIDTH,
|
||||
@@ -400,71 +421,82 @@ export function WorkspaceDraftComposer({
|
||||
[isCompactFormFactor, openInSidePane, serverId, workspaceId],
|
||||
);
|
||||
|
||||
const { formErrorMessage, isSubmitting, handleCreateFromInput, continueCreateFromAttempt } =
|
||||
useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||
draftId,
|
||||
getPendingServerId: () => serverId,
|
||||
initialAttempt: initialCreateAttempt,
|
||||
allowEmptyText: allowsEmptyAutoSubmit,
|
||||
validateBeforeSubmit: ({ text, attachments }) => {
|
||||
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit,
|
||||
attachments,
|
||||
});
|
||||
return validateDraftSubmission({
|
||||
text,
|
||||
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
isConnected,
|
||||
});
|
||||
},
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
buildDraftAgent: (attempt) =>
|
||||
buildDraftAgentSnapshot({
|
||||
attempt,
|
||||
serverId,
|
||||
tabId,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
|
||||
if (pendingAutoSubmit?.agentCreation) {
|
||||
const result = await pendingAutoSubmit.agentCreation.result;
|
||||
return { agentId: result.id, result };
|
||||
}
|
||||
return submitDraftCreateRequest({
|
||||
draftId,
|
||||
attempt,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceId: workspaceFields?.id ?? null,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
});
|
||||
},
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
onCreated(result);
|
||||
},
|
||||
});
|
||||
const {
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||
draftId,
|
||||
getPendingServerId: () => serverId,
|
||||
initialAttempt: initialCreateAttempt,
|
||||
allowEmptyText: allowsEmptyAutoSubmit,
|
||||
validateBeforeSubmit: ({ text, attachments }) => {
|
||||
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit,
|
||||
attachments,
|
||||
});
|
||||
return validateDraftSubmission({
|
||||
text,
|
||||
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
isConnected,
|
||||
});
|
||||
},
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
buildDraftAgent: (attempt) =>
|
||||
buildDraftAgentSnapshot({
|
||||
attempt,
|
||||
serverId,
|
||||
tabId,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
|
||||
if (pendingAutoSubmit?.agentCreation) {
|
||||
const result = await pendingAutoSubmit.agentCreation.result;
|
||||
return { agentId: result.id, result };
|
||||
}
|
||||
return submitDraftCreateRequest({
|
||||
draftId,
|
||||
attempt,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceId: workspaceFields?.id ?? null,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
});
|
||||
},
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
onCreated(result);
|
||||
},
|
||||
});
|
||||
const turnPresentation = useMemo(
|
||||
() => resolveTurnPresentation(TURN_LIVENESS_IDLE, pendingMessageSubmissions.length > 0),
|
||||
[pendingMessageSubmissions],
|
||||
);
|
||||
useAgentControlCommandCenterActions({
|
||||
sourceId: `draft:${serverId}:${tabId}`,
|
||||
enabled: isPaneFocused && !isSubmitting,
|
||||
@@ -568,49 +600,76 @@ export function WorkspaceDraftComposer({
|
||||
}),
|
||||
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
|
||||
);
|
||||
return (
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.formErrorRow}>
|
||||
<View style={styles.formError}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
const dockContent = (
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{importPillPress && !isComposerFullscreen ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
) : (
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
||||
<View style={styles.configSection}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
textSource={draftInput.textSource}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraft={composerState.commandDraft}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<ComposerDock fullscreen={isComposerFullscreen}>
|
||||
{dockContent}
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
{importPillPress && !isComposerFullscreen ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
textSource={draftInput.textSource}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraft={composerState.commandDraft}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</View>
|
||||
</ComposerDock>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
const animatedStaticStyles = RNStyleSheet.create({
|
||||
@@ -623,10 +682,27 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
formErrorRow: {
|
||||
container: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
streamContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
configScrollContent: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
configSection: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
importPillRow: {
|
||||
width: "100%",
|
||||
@@ -646,10 +722,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
flexDirection: "row",
|
||||
},
|
||||
formError: {
|
||||
width: "100%",
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
marginBottom: theme.spacing[2],
|
||||
errorContainer: {
|
||||
marginTop: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
|
||||
@@ -33,16 +33,9 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
controlSizingForLayout,
|
||||
TOOLBAR_CONTROL_GAP,
|
||||
TOOLBAR_DISC_GLYPH_SIZES,
|
||||
TOOLBAR_DISC_SIZE,
|
||||
TOOLBAR_RINGS,
|
||||
toolbarGlyphsFor,
|
||||
toolbarGlyphTone,
|
||||
type ToolbarGlyph,
|
||||
toolbarSlot,
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
} 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 +259,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 +1081,8 @@ function ComposerForgeBinding({
|
||||
}
|
||||
|
||||
interface ComposerCancelButtonProps {
|
||||
discGlyphSize: number;
|
||||
cancelButtonStyle: (state: PressableStateCallbackType) => object[];
|
||||
buttonIconSize: number;
|
||||
cancelButtonStyle: (object | undefined)[];
|
||||
handleCancelAgent: () => void;
|
||||
isConnected: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
@@ -1100,7 +1091,7 @@ interface ComposerCancelButtonProps {
|
||||
}
|
||||
|
||||
function ComposerCancelButton({
|
||||
discGlyphSize,
|
||||
buttonIconSize,
|
||||
cancelButtonStyle,
|
||||
handleCancelAgent,
|
||||
isConnected,
|
||||
@@ -1114,7 +1105,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 (
|
||||
@@ -1126,7 +1117,7 @@ function ComposerCancelButton({
|
||||
accessibilityRole="button"
|
||||
style={cancelButtonStyle}
|
||||
>
|
||||
<View style={styles.cancelButtonDisc}>{icon}</View>
|
||||
{icon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
@@ -1139,7 +1130,7 @@ function ComposerCancelButton({
|
||||
}
|
||||
|
||||
interface ComposerVoiceModeButtonProps {
|
||||
glyph: ToolbarGlyph;
|
||||
buttonIconSize: number;
|
||||
handleToggleRealtimeVoice: () => void;
|
||||
isConnected: boolean;
|
||||
isVoiceSwitching: boolean;
|
||||
@@ -1150,25 +1141,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 +1182,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 +1310,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 +2011,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 +2020,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 +2038,7 @@ function ComposerContentImpl({
|
||||
),
|
||||
[
|
||||
agentInterruptKeys,
|
||||
discGlyphSize,
|
||||
buttonIconSize,
|
||||
cancelButtonStyle,
|
||||
handleCancelAgent,
|
||||
isCancellingAgent,
|
||||
@@ -2059,34 +2047,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 +2092,7 @@ function ComposerContentImpl({
|
||||
maxTokens={agentState.contextWindowMaxTokens}
|
||||
usedTokens={agentState.contextWindowUsedTokens}
|
||||
totalCostUsd={agentState.totalCostUsd}
|
||||
toolbarRing={TOOLBAR_RINGS[controlSizing]}
|
||||
glyphSize={buttonIconSize}
|
||||
/>
|
||||
</View>
|
||||
) : null,
|
||||
@@ -2117,7 +2103,7 @@ function ComposerContentImpl({
|
||||
agentState.contextWindowMaxTokens,
|
||||
agentState.contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
controlSizing,
|
||||
buttonIconSize,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2503,7 +2489,7 @@ function ComposerContentImpl({
|
||||
disabled={isSubmitLoading}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
voiceModeContent={voiceModeContent}
|
||||
rightContent={rightContent}
|
||||
activeActionContent={activeActionContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
@@ -2610,49 +2596,42 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
cancelButton: {
|
||||
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
cancelButtonDisc: {
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -31,24 +31,9 @@ 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 +169,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 +251,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 +341,21 @@ 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" />
|
||||
</View>
|
||||
);
|
||||
return <Square size={buttonIconSize} color="white" fill="white" />;
|
||||
}
|
||||
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 +394,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 +408,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 +803,7 @@ function SendButtonTooltip({
|
||||
submitIcon,
|
||||
submitLabel,
|
||||
submitButtonTestID,
|
||||
glyph,
|
||||
buttonIconSize,
|
||||
sendKeys,
|
||||
sendTooltipLabel,
|
||||
}: {
|
||||
@@ -860,7 +820,7 @@ function SendButtonTooltip({
|
||||
submitIcon: "arrow" | "return";
|
||||
submitLabel: string | undefined;
|
||||
submitButtonTestID: string | undefined;
|
||||
glyph: ToolbarGlyph;
|
||||
buttonIconSize: number;
|
||||
sendKeys: ShortcutChord | null | undefined;
|
||||
sendTooltipLabel: string;
|
||||
}) {
|
||||
@@ -877,14 +837,12 @@ function SendButtonTooltip({
|
||||
testID={submitButtonTestID}
|
||||
style={sendButtonCombinedStyle}
|
||||
>
|
||||
<View style={submitLabel ? styles.sendButtonDiscLabeled : styles.sendButtonDisc}>
|
||||
<SendButtonContent
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
glyph={glyph}
|
||||
/>
|
||||
</View>
|
||||
<SendButtonContent
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
buttonIconSize={buttonIconSize}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
|
||||
@@ -1175,7 +1133,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 +1181,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 +1209,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 +1218,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 +1259,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
disabled,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
voiceModeContent,
|
||||
rightContent,
|
||||
activeActionContent,
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
@@ -1335,8 +1289,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,21 +1912,20 @@ 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 && styles.voiceButtonRecording,
|
||||
],
|
||||
[isDictating, isDictationStartEnabled],
|
||||
);
|
||||
@@ -2013,25 +1965,21 @@ 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 }) => [
|
||||
const sendButtonCombinedStyle = useMemo(
|
||||
() => [
|
||||
styles.sendButton,
|
||||
Boolean(pressed) && styles.discPressed,
|
||||
submitLabel ? styles.sendButtonLabeled : undefined,
|
||||
isPresentedSendDisabled && styles.buttonDisabled,
|
||||
],
|
||||
[isPresentedSendDisabled],
|
||||
[isPresentedSendDisabled, submitLabel],
|
||||
);
|
||||
const overlayContainerStyle = useMemo(
|
||||
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
|
||||
@@ -2043,10 +1991,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 +2003,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 +2027,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 +2048,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
submitIcon,
|
||||
submitLabel,
|
||||
submitButtonTestID,
|
||||
toolbarGlyph,
|
||||
buttonIconSize,
|
||||
sendShortcut,
|
||||
sendTooltipLabel,
|
||||
],
|
||||
@@ -2174,25 +2122,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 +2154,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
voiceMuteToggleKeys={voiceMuteToggleKeys}
|
||||
dictationToggleKeys={dictationToggleKeys}
|
||||
/>
|
||||
{voiceModeContent}
|
||||
{rightContent}
|
||||
{sendButton}
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
buttonRowStyle,
|
||||
mode.showAttachments,
|
||||
isConnected,
|
||||
disabled,
|
||||
@@ -2248,7 +2179,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
voiceMuteToggleKeys,
|
||||
dictationToggleKeys,
|
||||
voiceModeContent,
|
||||
rightContent,
|
||||
sendButton,
|
||||
],
|
||||
);
|
||||
@@ -2316,7 +2247,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 +2288,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,80 +2375,64 @@ 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,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.destructive,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendButton: {
|
||||
minWidth: TOOLBAR_DISC_SIZE,
|
||||
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendButtonDisc: {
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sendButtonDiscLabeled: {
|
||||
minWidth: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
sendButtonLabeled: {
|
||||
width: "auto",
|
||||
minWidth: TOOLBAR_CONTROL_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendButtonLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -2517,9 +2442,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
||||
import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
||||
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
@@ -144,10 +144,21 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
["status", "providers_snapshot_update"],
|
||||
() => {},
|
||||
),
|
||||
observeCheckoutDiff(cwd, compare) {
|
||||
observeDiff(input) {
|
||||
const cwd = input.cwd;
|
||||
let mode: "uncommitted" | "base" | "all" = "uncommitted";
|
||||
if (input.head.kind === "rev") mode = "base";
|
||||
else if (input.mergeBase) mode = "all";
|
||||
const compare = {
|
||||
mode,
|
||||
...(input.base.kind === "rev" && input.base.rev !== "HEAD"
|
||||
? { baseRef: input.base.rev }
|
||||
: {}),
|
||||
ignoreWhitespace: input.ignoreWhitespace,
|
||||
};
|
||||
const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`;
|
||||
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
|
||||
const handle = observe(
|
||||
const handle = observe<SubscribeCheckoutDiffResponseMessage["payload"]>(
|
||||
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
|
||||
["checkout_diff_update", "subscribe_checkout_diff_response"],
|
||||
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
|
||||
@@ -155,9 +166,43 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
"subscriptionId" in message.payload &&
|
||||
message.payload.subscriptionId === subscriptionId,
|
||||
);
|
||||
if (!config.rejectCheckoutDiffSubscribe) return handle;
|
||||
return {
|
||||
const modern: OwnedSubscription<DiffState> = {
|
||||
...handle,
|
||||
ready: Promise.resolve({
|
||||
phase: "ready",
|
||||
revision: 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: [],
|
||||
diffTooLarge: false,
|
||||
subscriptionId,
|
||||
}),
|
||||
subscribe: (observer) =>
|
||||
handle.subscribe({
|
||||
snapshot: (payload) =>
|
||||
observer.snapshot({
|
||||
phase: "ready",
|
||||
revision: 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: payload.files,
|
||||
diffTooLarge: payload.diffTooLarge === true,
|
||||
subscriptionId,
|
||||
}),
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update")
|
||||
observer.snapshot({
|
||||
phase: "ready",
|
||||
revision: 2,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: message.payload.files,
|
||||
diffTooLarge: message.payload.diffTooLarge === true,
|
||||
subscriptionId,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
if (!config.rejectCheckoutDiffSubscribe) return modern;
|
||||
return {
|
||||
...modern,
|
||||
subscribe: (observer) => {
|
||||
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
|
||||
return () => {};
|
||||
@@ -328,7 +373,7 @@ describe("server data push router", () => {
|
||||
const first = publish(files, "first");
|
||||
const same = publish(structuredClone(files), "reopen");
|
||||
expect(same.files).toBe(first.files);
|
||||
expect(same.requestId).toBe("reopen");
|
||||
expect(same.requestId).toBe("diff:1:ready");
|
||||
const changed = structuredClone(files);
|
||||
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
||||
const next = publish(changed, "edit");
|
||||
@@ -430,7 +475,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: "diff-1",
|
||||
requestId: "diff:1:ready",
|
||||
});
|
||||
|
||||
fake.emit({
|
||||
@@ -449,7 +494,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: `subscription:${serverSubscriptionId}`,
|
||||
requestId: "diff:2:ready",
|
||||
});
|
||||
|
||||
unsubscribeObserver();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { checkoutDiffSnapshot, type CheckoutDiffSnapshot } from "@/git/diff-snapshot";
|
||||
import type { DiffState } from "@getpaseo/client";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
@@ -23,14 +25,9 @@ type ProvidersSnapshotUpdateMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "providers_snapshot_update" }
|
||||
>;
|
||||
type SubscribeCheckoutDiffResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "subscribe_checkout_diff_response" }
|
||||
>;
|
||||
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
||||
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
||||
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
|
||||
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
interface CheckoutDiffCompare {
|
||||
@@ -49,7 +46,7 @@ interface CheckoutDiffRoute {
|
||||
}
|
||||
|
||||
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
|
||||
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
|
||||
subscription: OwnedSubscription<DiffState>;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalsRoute {
|
||||
@@ -75,7 +72,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
|
||||
interface ServerDataPushClient {
|
||||
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
|
||||
observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"];
|
||||
observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"];
|
||||
observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"];
|
||||
observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"];
|
||||
}
|
||||
|
||||
@@ -339,34 +336,34 @@ function reconcileCheckoutDiffSubscriptions(input: {
|
||||
}
|
||||
for (const [key, desired] of input.desired) {
|
||||
if (input.active.has(key)) continue;
|
||||
const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare);
|
||||
const { mode, baseRef, ignoreWhitespace } = desired.compare;
|
||||
const revision = mode === "uncommitted" ? "HEAD" : baseRef;
|
||||
if (!revision) continue;
|
||||
const subscription = input.client.observeDiff({
|
||||
cwd: desired.cwd,
|
||||
base: { kind: "rev", rev: revision },
|
||||
head: mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" },
|
||||
mergeBase: mode !== "uncommitted",
|
||||
ignoreWhitespace,
|
||||
});
|
||||
const registration = { ...desired, subscription };
|
||||
input.active.set(key, registration);
|
||||
const apply = (
|
||||
payload: Omit<CheckoutDiffResponsePayload, "requestId"> & { requestId?: string },
|
||||
) => {
|
||||
if (input.active.get(key) !== registration) return;
|
||||
const { subscriptionId, ...snapshot } = payload;
|
||||
setCheckoutDiffPayload({
|
||||
activeCheckoutDiffSubscriptions: input.active,
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
subscriptionId: key,
|
||||
payload: {
|
||||
...snapshot,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
requestId: payload.requestId ?? `subscription:${subscriptionId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
subscription.subscribe({
|
||||
snapshot: apply,
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") apply(message.payload);
|
||||
snapshot: (state) => {
|
||||
if (input.active.get(key) !== registration) return;
|
||||
const snapshot = checkoutDiffSnapshot(desired.cwd, state);
|
||||
setCheckoutDiffPayload({
|
||||
activeCheckoutDiffSubscriptions: input.active,
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
subscriptionId: key,
|
||||
payload: { ...snapshot, files: orderCheckoutDiffFiles(snapshot.files) },
|
||||
});
|
||||
},
|
||||
update: () => {},
|
||||
error: (error) => {
|
||||
if (input.active.get(key) === registration) input.active.delete(key);
|
||||
console.error("[server-data] observeCheckoutDiff failed", {
|
||||
console.error("[server-data] observeDiff failed", {
|
||||
serverId: input.serverId,
|
||||
cwd: desired.cwd,
|
||||
error,
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
type DiffTreeRow,
|
||||
} from "@/git/diff-tree";
|
||||
import { FileHeader } from "@/git/file-header";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
|
||||
export interface ChangedFilesTreeProps {
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
collapsedFolderPaths: string[];
|
||||
|
||||
@@ -13,6 +13,6 @@ export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[
|
||||
// chains, so it is skipped; the collapsed set is empty because the flat list
|
||||
// always carries every file, whatever the rail has collapsed.
|
||||
return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) =>
|
||||
row.kind === "file" ? [row.file] : [],
|
||||
row.kind === "file" ? [files[row.fileIndex]] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
|
||||
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
@@ -1095,6 +1096,7 @@ interface DiffBodyContentProps {
|
||||
emptyMessage: string;
|
||||
emptyAction: ChangesEmptyAction | null;
|
||||
children: ReactElement;
|
||||
loadingLabel: string;
|
||||
checkingRepositoryLabel: string;
|
||||
notRepositoryLabel: string;
|
||||
}
|
||||
@@ -1111,11 +1113,16 @@ function DiffBodyContent({
|
||||
emptyAction,
|
||||
children,
|
||||
checkingRepositoryLabel,
|
||||
loadingLabel,
|
||||
notRepositoryLabel,
|
||||
}: DiffBodyContentProps) {
|
||||
if (isStatusLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
|
||||
</View>
|
||||
@@ -1137,12 +1144,16 @@ function DiffBodyContent({
|
||||
}
|
||||
if (isDiffLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (diffTooLarge) {
|
||||
if (diffTooLarge && !hasChanges) {
|
||||
return <DiffTooLargeState />;
|
||||
}
|
||||
if (diffErrorMessage) {
|
||||
@@ -1321,7 +1332,7 @@ function ChangesTreeRail({
|
||||
}: {
|
||||
shown: boolean;
|
||||
children: ReactElement;
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1361,7 +1372,7 @@ function ChangesBody({
|
||||
children: ReactElement;
|
||||
desktopTreeVisible: boolean;
|
||||
isMobile: boolean;
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1586,6 +1597,7 @@ export function ChangesSurface({
|
||||
selectBase: handleSelectBase,
|
||||
selectAll: handleSelectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
@@ -1835,6 +1847,7 @@ export function ChangesSurface({
|
||||
hasChanges={hasChanges}
|
||||
emptyMessage={emptyMessage}
|
||||
emptyAction={emptyAction}
|
||||
loadingLabel={t("common.loading")}
|
||||
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
|
||||
notRepositoryLabel={t("workspace.git.diff.notRepository")}
|
||||
>
|
||||
@@ -1865,7 +1878,7 @@ export function ChangesSurface({
|
||||
presentation={presentation}
|
||||
desktopTreeVisible={desktopTreeVisible}
|
||||
isMobile={isMobile}
|
||||
files={files}
|
||||
files={treeFiles}
|
||||
mode={workingMode}
|
||||
onSelectFile={handleSelectTreeFile}
|
||||
treeWidth={instanceState.treeWidth}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { replaceEqualDeep } from "@tanstack/react-query";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import type { CheckoutDiffSnapshot } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
|
||||
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
type DiffPayload = CheckoutDiffSnapshot;
|
||||
|
||||
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
|
||||
export function retainDiffFiles(
|
||||
@@ -30,6 +31,7 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
|
||||
error === old.error &&
|
||||
old.cwd === next.cwd &&
|
||||
old.requestId === next.requestId &&
|
||||
old.entries === next.entries &&
|
||||
old.diffTooLarge === next.diffTooLarge
|
||||
? old
|
||||
: { ...next, files, error };
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { DiffState, DiffEntry } from "@getpaseo/client";
|
||||
import type {
|
||||
DiffError,
|
||||
ParsedDiffFile,
|
||||
SubscribeCheckoutDiffRequest,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
export type DiffFileHeader = Omit<ParsedDiffFile, "hunks">;
|
||||
export interface CheckoutDiffSnapshot {
|
||||
requestId: string;
|
||||
cwd: string;
|
||||
files: ParsedDiffFile[];
|
||||
entries?: DiffEntry[];
|
||||
error: DiffError | null;
|
||||
diffTooLarge?: boolean;
|
||||
}
|
||||
export function checkoutDiffSnapshot(cwd: string, state: DiffState): CheckoutDiffSnapshot {
|
||||
const common = { cwd, requestId: `diff:${state.revision}:${state.phase}` };
|
||||
if (state.phase === "loading")
|
||||
return { ...common, files: [], entries: state.entries, error: null };
|
||||
if (state.phase === "error") return { ...common, files: [], error: state.error };
|
||||
return { ...common, files: state.files, error: null, diffTooLarge: state.diffTooLarge };
|
||||
}
|
||||
export function diffEntryHeaders(entries: DiffEntry[]): DiffFileHeader[] {
|
||||
return entries.map((entry) => ({
|
||||
path: entry.path,
|
||||
...(entry.oldPath === null ? {} : { oldPath: entry.oldPath }),
|
||||
isNew: entry.change === "added",
|
||||
isDeleted: entry.change === "deleted",
|
||||
additions: entry.additions,
|
||||
deletions: entry.deletions,
|
||||
status: entry.content === "binary" ? "binary" : "ok",
|
||||
}));
|
||||
}
|
||||
export interface CheckoutDiffResult {
|
||||
files: ParsedDiffFile[];
|
||||
entries: DiffEntry[] | null;
|
||||
payloadError: CheckoutDiffSnapshot["error"];
|
||||
diffTooLarge: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result derives from the payload alone. Until a payload arrives there is no
|
||||
* diff to describe, so the boundary reports loading rather than an empty diff —
|
||||
* whether the query is idle (an inactive retained panel), in flight, or the host
|
||||
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
|
||||
* and "No changes" for a diff nobody has fetched yet.
|
||||
*/
|
||||
export function deriveCheckoutDiffResult(payload: CheckoutDiffSnapshot | null): CheckoutDiffResult {
|
||||
const payloadError = payload?.error ?? null;
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
entries: payload?.entries ?? null,
|
||||
payloadError,
|
||||
diffTooLarge: payload?.diffTooLarge === true,
|
||||
isLoading: payload === null || payload.entries !== undefined,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkoutDiffComparisonError(
|
||||
compare: Pick<SubscribeCheckoutDiffRequest["compare"], "mode" | "baseRef">,
|
||||
): DiffError | null {
|
||||
if (compare.mode === "uncommitted" || compare.baseRef?.trim()) return null;
|
||||
return {
|
||||
code: "INVALID_COMPARE",
|
||||
message: "Comparison base is unavailable. Select Uncommitted to view pending changes.",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
|
||||
// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view
|
||||
// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view
|
||||
// renders. The tree renders on every form factor, consistent with the Files
|
||||
// explorer.
|
||||
//
|
||||
@@ -17,7 +17,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
|
||||
export interface DiffTreeFileNode {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
fileIndex: number;
|
||||
/** basename, e.g. "diff-pane.tsx" */
|
||||
name: string;
|
||||
@@ -47,7 +47,7 @@ export interface DiffTreeFolderRow {
|
||||
|
||||
export interface DiffTreeFileRow {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
fileIndex: number;
|
||||
depth: number;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void {
|
||||
}
|
||||
|
||||
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
|
||||
export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
|
||||
export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode {
|
||||
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
|
||||
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ import {
|
||||
fileNameForPath,
|
||||
formatDiffCount,
|
||||
} from "@/git/file-header-presentation";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
export interface FileHeaderProps {
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
bodyVisible: boolean;
|
||||
showsBodyState?: boolean;
|
||||
@@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
|
||||
return showsBodyState ? bodyVisible : undefined;
|
||||
}
|
||||
|
||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
|
||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) {
|
||||
if (!canvasRendered) return undefined;
|
||||
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
|
||||
];
|
||||
}
|
||||
|
||||
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
|
||||
function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" {
|
||||
return diffFileChangeKind(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { deriveCheckoutDiffResult } from "./use-diff-query";
|
||||
import { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
|
||||
function createParsedDiffFile(path: string): ParsedDiffFile {
|
||||
return {
|
||||
@@ -59,6 +59,7 @@ describe("deriveCheckoutDiffResult", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
files: [],
|
||||
entries: null,
|
||||
payloadError: error,
|
||||
diffTooLarge: true,
|
||||
isLoading: false,
|
||||
@@ -68,3 +69,27 @@ describe("deriveCheckoutDiffResult", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the announced tree while file bodies are still loading", () => {
|
||||
const entries = [
|
||||
{
|
||||
path: "src/app.ts",
|
||||
oldPath: null,
|
||||
change: "modified",
|
||||
content: "text",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
},
|
||||
] as const;
|
||||
const result = deriveCheckoutDiffResult({ ...createPayload([]), entries: [...entries] });
|
||||
expect(result.isLoading).toBe(true);
|
||||
expect(result.entries).toEqual(entries);
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles a comparison without a base and leaves Uncommitted available", async () => {
|
||||
const { checkoutDiffComparisonError } = await import("./diff-snapshot");
|
||||
expect(checkoutDiffComparisonError({ mode: "all" })).toMatchObject({ code: "INVALID_COMPARE" });
|
||||
expect(checkoutDiffComparisonError({ mode: "base" })).toMatchObject({ code: "INVALID_COMPARE" });
|
||||
expect(checkoutDiffComparisonError({ mode: "uncommitted" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,13 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { checkoutDiffPushRoute } from "@/data/push-router";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
checkoutDiffComparisonError,
|
||||
deriveCheckoutDiffResult,
|
||||
type CheckoutDiffSnapshot,
|
||||
} from "./diff-snapshot";
|
||||
export { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -18,7 +24,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
queryScope?: string;
|
||||
}
|
||||
|
||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
type CheckoutDiffQueryPayload = CheckoutDiffSnapshot;
|
||||
|
||||
// Re-export the canonical protocol type so all consumers share one definition.
|
||||
export type { ParsedDiffFile };
|
||||
@@ -77,7 +83,11 @@ export function useCheckoutDiffQuery({
|
||||
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
||||
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
||||
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
|
||||
const comparisonError =
|
||||
queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null;
|
||||
const routeEnabled = Boolean(
|
||||
queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError,
|
||||
);
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
@@ -97,46 +107,13 @@ export function useCheckoutDiffQuery({
|
||||
}),
|
||||
});
|
||||
|
||||
return deriveCheckoutDiffResult(
|
||||
const boundaryError =
|
||||
requiresUpdate && isConnected
|
||||
? {
|
||||
requestId: "unsupported",
|
||||
cwd,
|
||||
files: [],
|
||||
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
|
||||
}
|
||||
? { code: "UNKNOWN" as const, message: "Update the host to view All changes" }
|
||||
: comparisonError;
|
||||
return deriveCheckoutDiffResult(
|
||||
boundaryError
|
||||
? { requestId: "unavailable", cwd, files: [], error: boundaryError }
|
||||
: (query.data ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
export interface CheckoutDiffResult {
|
||||
files: ParsedDiffFile[];
|
||||
payloadError: CheckoutDiffQueryPayload["error"];
|
||||
diffTooLarge: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result derives from the payload alone. Until a payload arrives there is no
|
||||
* diff to describe, so the boundary reports loading rather than an empty diff —
|
||||
* whether the query is idle (an inactive retained panel), in flight, or the host
|
||||
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
|
||||
* and "No changes" for a diff nobody has fetched yet.
|
||||
*/
|
||||
export function deriveCheckoutDiffResult(
|
||||
payload: CheckoutDiffQueryPayload | null,
|
||||
): CheckoutDiffResult {
|
||||
const payloadError = payload?.error ?? null;
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
diffTooLarge: payload?.diffTooLarge === true,
|
||||
isLoading: payload === null,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useReviewAttachmentSnapshot,
|
||||
useReviewFeedback,
|
||||
} from "@/review";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { diffEntryHeaders } from "./diff-snapshot";
|
||||
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
|
||||
@@ -44,7 +46,11 @@ export function useWorkingDiff({
|
||||
const statusErrorMessage =
|
||||
status?.error?.message ??
|
||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
const modernDiff = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.diffSubscription === true,
|
||||
);
|
||||
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
|
||||
const baseRef = (modernDiff ? gitStatus?.comparisonBaseRef : gitStatus?.baseRef) ?? undefined;
|
||||
const currentBranchName =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||
|
||||
@@ -59,6 +65,7 @@ export function useWorkingDiff({
|
||||
|
||||
const {
|
||||
files,
|
||||
entries,
|
||||
payloadError: diffPayloadError,
|
||||
diffTooLarge,
|
||||
isLoading: isDiffLoading,
|
||||
@@ -71,6 +78,7 @@ export function useWorkingDiff({
|
||||
enabled: enabled && isGit,
|
||||
queryScope,
|
||||
});
|
||||
const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]);
|
||||
const reviewDraftKey = useMemo(
|
||||
() =>
|
||||
buildReviewDraftKey({
|
||||
@@ -112,6 +120,7 @@ export function useWorkingDiff({
|
||||
selectBase,
|
||||
selectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
|
||||
@@ -1236,7 +1236,6 @@ export const ar: TranslationResources = {
|
||||
hosts: "المضيفون",
|
||||
settings: "إعدادات",
|
||||
closeSidebar: "إغلاق الشريط الجانبي",
|
||||
moreActions: "المزيد من الإجراءات",
|
||||
},
|
||||
footer: {
|
||||
usage: "الاستخدام",
|
||||
@@ -1341,9 +1340,6 @@ export const ar: TranslationResources = {
|
||||
hostDisconnected: "Host غير متصل",
|
||||
hideFailed: "فشل في إخفاء مساحة العمل",
|
||||
archiveFailed: "فشل في أرشفة مساحة العمل",
|
||||
archived: "تمت أرشفة مساحة العمل",
|
||||
undoArchive: "تراجع",
|
||||
undoArchiveFailed: "تعذّر التراجع عن الأرشفة",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1244,7 +1244,6 @@ export const en = {
|
||||
hosts: "Hosts",
|
||||
settings: "Settings",
|
||||
closeSidebar: "Close sidebar",
|
||||
moreActions: "More actions",
|
||||
},
|
||||
footer: {
|
||||
usage: "Usage",
|
||||
@@ -1349,9 +1348,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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1272,7 +1272,6 @@ export const es: TranslationResources = {
|
||||
hosts: "Hosts",
|
||||
settings: "Ajustes",
|
||||
closeSidebar: "Cerrar barra lateral",
|
||||
moreActions: "Más acciones",
|
||||
},
|
||||
footer: {
|
||||
usage: "Uso",
|
||||
@@ -1377,9 +1376,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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1266,7 +1266,6 @@ export const fr: TranslationResources = {
|
||||
hosts: "Hôtes",
|
||||
settings: "Paramètres",
|
||||
closeSidebar: "Fermer la barre latérale",
|
||||
moreActions: "Plus d’actions",
|
||||
},
|
||||
footer: {
|
||||
usage: "Utilisation",
|
||||
@@ -1371,9 +1370,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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1250,7 +1250,6 @@ export const ja: TranslationResources = {
|
||||
hosts: "ホスト",
|
||||
settings: "設定",
|
||||
closeSidebar: "サイドバーを閉じる",
|
||||
moreActions: "その他のアクション",
|
||||
},
|
||||
footer: {
|
||||
usage: "使用状況",
|
||||
@@ -1355,9 +1354,6 @@ export const ja: TranslationResources = {
|
||||
hostDisconnected: "ホストが接続されていません",
|
||||
hideFailed: "ワークスペースの非表示に失敗しました",
|
||||
archiveFailed: "ワークスペースのアーカイブに失敗しました",
|
||||
archived: "ワークスペースをアーカイブしました",
|
||||
undoArchive: "元に戻す",
|
||||
undoArchiveFailed: "アーカイブを元に戻せませんでした",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1243,7 +1243,6 @@ export const ko: TranslationResources = {
|
||||
hosts: "호스트",
|
||||
settings: "설정",
|
||||
closeSidebar: "사이드바 닫기",
|
||||
moreActions: "추가 작업",
|
||||
},
|
||||
footer: {
|
||||
usage: "사용량",
|
||||
@@ -1348,9 +1347,6 @@ export const ko: TranslationResources = {
|
||||
hostDisconnected: "호스트가 연결되어 있지 않습니다",
|
||||
hideFailed: "워크스페이스를 숨기지 못했습니다",
|
||||
archiveFailed: "워크스페이스를 보관하지 못했습니다.",
|
||||
archived: "워크스페이스를 보관했습니다",
|
||||
undoArchive: "실행 취소",
|
||||
undoArchiveFailed: "보관을 취소하지 못했습니다",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1262,7 +1262,6 @@ export const ptBR: TranslationResources = {
|
||||
hosts: "Hosts",
|
||||
settings: "Configurações",
|
||||
closeSidebar: "Fechar barra lateral",
|
||||
moreActions: "Mais ações",
|
||||
},
|
||||
footer: {
|
||||
usage: "Uso",
|
||||
@@ -1367,9 +1366,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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1254,7 +1254,6 @@ export const ru: TranslationResources = {
|
||||
hosts: "Хосты",
|
||||
settings: "Настройки",
|
||||
closeSidebar: "Закрыть боковую панель",
|
||||
moreActions: "Дополнительные действия",
|
||||
},
|
||||
footer: {
|
||||
usage: "Использование",
|
||||
@@ -1359,9 +1358,6 @@ export const ru: TranslationResources = {
|
||||
hostDisconnected: "Хост не подключён",
|
||||
hideFailed: "Не удалось скрыть рабочее пространство",
|
||||
archiveFailed: "Не удалось архивировать рабочее пространство",
|
||||
archived: "Рабочее пространство архивировано",
|
||||
undoArchive: "Отменить",
|
||||
undoArchiveFailed: "Не удалось отменить архивацию",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1228,7 +1228,6 @@ export const zhCN: TranslationResources = {
|
||||
hosts: "Hosts",
|
||||
settings: "设置",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
moreActions: "更多操作",
|
||||
},
|
||||
footer: {
|
||||
usage: "使用情况",
|
||||
@@ -1330,9 +1329,6 @@ export const zhCN: TranslationResources = {
|
||||
hostDisconnected: "Host 未连接",
|
||||
hideFailed: "隐藏 workspace 失败",
|
||||
archiveFailed: "归档工作区失败",
|
||||
archived: "工作区已归档",
|
||||
undoArchive: "撤销",
|
||||
undoArchiveFailed: "无法撤销归档",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -78,8 +78,7 @@ import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab";
|
||||
import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -91,13 +90,14 @@ import {
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamItem, TodoEntry } from "@/types/stream";
|
||||
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
||||
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
@@ -225,29 +225,6 @@ function AgentLoadErrorView({ message, onRetry }: { message: string; onRetry: ()
|
||||
);
|
||||
}
|
||||
|
||||
/** A draft has no agent to wait for; an agent shows its loading and error states until ready. */
|
||||
function renderConversationNonReadyView(args: {
|
||||
draft: DraftConversation | null;
|
||||
agentId: string | undefined;
|
||||
cwd: string | null;
|
||||
viewState: AgentScreenViewState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
t: TFunction;
|
||||
onRetryLoad: () => void;
|
||||
}): React.ReactElement | null {
|
||||
if (args.draft) {
|
||||
return null;
|
||||
}
|
||||
const nonReadyView = renderChatAgentNonReadyView(args);
|
||||
if (nonReadyView) {
|
||||
return nonReadyView;
|
||||
}
|
||||
invariant(args.agentId, "agent id is defined when agent content is ready");
|
||||
invariant(args.effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
invariant(args.cwd, "agent cwd is defined when agent content is ready");
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderChatAgentNonReadyView(args: {
|
||||
viewState: AgentScreenViewState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
@@ -326,31 +303,17 @@ function storeFetchedAgentDetail(input: {
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
type ConversationTarget = Extract<WorkspaceTabTarget, { kind: "agent" } | { kind: "draft" }>;
|
||||
|
||||
/** The agent a conversation shows; a draft has none until its first prompt creates one. */
|
||||
function resolveConversationAgentId(target: ConversationTarget): string | undefined {
|
||||
return target.kind === "agent" ? target.agentId.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drafts and agents render through one chain so the timeline stays mounted when the draft's
|
||||
* first prompt creates the agent and the tab retargets. The agent gates are idle for a draft.
|
||||
*/
|
||||
export function AgentConversationPanel() {
|
||||
function AgentPanel() {
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(
|
||||
target.kind === "agent" || target.kind === "draft",
|
||||
"AgentConversationPanel requires an agent or draft target",
|
||||
);
|
||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||
|
||||
return (
|
||||
<RetainedChatContent>
|
||||
<AgentPanelContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
target={target}
|
||||
agentId={target.agentId}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
/>
|
||||
@@ -358,6 +321,55 @@ export function AgentConversationPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function DraftPanel() {
|
||||
const {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId,
|
||||
target,
|
||||
openFileInWorkspace,
|
||||
openImportSheet,
|
||||
retargetCurrentTab,
|
||||
} = usePaneContext();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||
|
||||
const handleCreated = useCallback(
|
||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
||||
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
},
|
||||
[retargetCurrentTab, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceDraftAgentTab
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
initialSetup={target.setup}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
onCreated={handleCreated}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentConversationPanel() {
|
||||
const { target } = usePaneContext();
|
||||
if (target.kind === "draft") {
|
||||
return <DraftPanel />;
|
||||
}
|
||||
if (target.kind === "agent") {
|
||||
return <AgentPanel />;
|
||||
}
|
||||
invariant(false, "AgentConversationPanel requires an agent or draft target");
|
||||
}
|
||||
|
||||
export const agentPanelRegistration = definePanel("agent", {
|
||||
component: AgentConversationPanel,
|
||||
useDescriptor: useAgentDescriptor,
|
||||
@@ -432,18 +444,18 @@ type AgentLookupState =
|
||||
function AgentPanelContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: ConversationTarget;
|
||||
agentId: string;
|
||||
isPaneFocused: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedAgentId = resolveConversationAgentId(target);
|
||||
const resolvedAgentId = agentId.trim() || undefined;
|
||||
const resolvedServerId = serverId.trim() || undefined;
|
||||
const daemons = useHosts();
|
||||
const runtimeServerId = resolvedServerId ?? "";
|
||||
@@ -472,7 +484,7 @@ function AgentPanelContent({
|
||||
: runtimeConnectionStatus;
|
||||
const lastConnectionError = runtimeLastError;
|
||||
|
||||
if (target.kind === "agent" && (!resolvedServerId || (!runtimeClient && !hasCachedAgent))) {
|
||||
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
|
||||
return (
|
||||
<AgentSessionUnavailableState
|
||||
serverLabel={serverLabel}
|
||||
@@ -486,9 +498,9 @@ function AgentPanelContent({
|
||||
|
||||
return (
|
||||
<AgentPanelBody
|
||||
serverId={resolvedServerId ?? serverId}
|
||||
serverId={resolvedServerId}
|
||||
workspaceId={workspaceId}
|
||||
target={target}
|
||||
agentId={resolvedAgentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={runtimeClient}
|
||||
isConnected={runtimeIsConnected}
|
||||
@@ -500,7 +512,7 @@ function AgentPanelContent({
|
||||
function AgentPanelBody({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
client,
|
||||
isConnected,
|
||||
@@ -508,14 +520,13 @@ function AgentPanelBody({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: ConversationTarget;
|
||||
agentId?: string;
|
||||
isPaneFocused: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const agentId = resolveConversationAgentId(target);
|
||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
@@ -630,7 +641,7 @@ function AgentPanelBody({
|
||||
}
|
||||
: null;
|
||||
|
||||
if (target.kind === "agent" && !agent) {
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
@@ -644,7 +655,7 @@ function AgentPanelBody({
|
||||
<ChatAgentContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
target={target}
|
||||
agentId={agentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={client}
|
||||
isConnected={isConnected}
|
||||
@@ -656,7 +667,7 @@ function AgentPanelBody({
|
||||
function ChatAgentContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
client,
|
||||
isConnected,
|
||||
@@ -664,15 +675,13 @@ function ChatAgentContent({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: ConversationTarget;
|
||||
agentId?: string;
|
||||
isPaneFocused: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tabId } = usePaneContext();
|
||||
const agentId = resolveConversationAgentId(target);
|
||||
const isPaneVisible = useRetainedPanelActive();
|
||||
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
@@ -979,23 +988,17 @@ function ChatAgentContent({
|
||||
visibilityCatchUpStatus,
|
||||
]);
|
||||
|
||||
const draft = useMemo<DraftConversation | null>(
|
||||
() =>
|
||||
target.kind === "draft"
|
||||
? { tabId, workspaceId, draftId: target.draftId, setup: target.setup }
|
||||
: null,
|
||||
[tabId, target, workspaceId],
|
||||
);
|
||||
const nonReadyView = renderConversationNonReadyView({
|
||||
draft,
|
||||
agentId,
|
||||
cwd: agentState.cwd,
|
||||
const nonReadyView = renderChatAgentNonReadyView({
|
||||
viewState,
|
||||
effectiveAgent,
|
||||
t,
|
||||
onRetryLoad: retryAgentLoad,
|
||||
});
|
||||
if (nonReadyView) return nonReadyView;
|
||||
invariant(agentId, "agent id is defined when agent content is ready");
|
||||
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
const agentCwd = agentState.cwd;
|
||||
invariant(agentCwd, "agent cwd is defined when agent content is ready");
|
||||
const showHistorySyncOverlay =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
@@ -1010,8 +1013,7 @@ function ChatAgentContent({
|
||||
<ChatAgentReadyContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId ?? tabId}
|
||||
draft={draft}
|
||||
agentId={agentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||
agentState={agentState}
|
||||
@@ -1029,7 +1031,7 @@ function ChatAgentContent({
|
||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||
showHistorySyncError={showHistorySyncError}
|
||||
isRetryingHistorySync={isRetryingHistorySync}
|
||||
cwd={agentState.cwd ?? ""}
|
||||
cwd={agentCwd}
|
||||
retryTimelineSync={retryTimelineSync}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
@@ -1042,7 +1044,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
draft,
|
||||
isPaneFocused,
|
||||
isArchivingCurrentAgent,
|
||||
agentState,
|
||||
@@ -1068,13 +1069,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
/** The agent's id, or the tab id while a draft has no agent yet. */
|
||||
agentId: string;
|
||||
draft: DraftConversation | null;
|
||||
isPaneFocused: boolean;
|
||||
isArchivingCurrentAgent: boolean;
|
||||
agentState: ChatAgentSelectedState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
effectiveAgent: AgentScreenAgent;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
toastApi: ToastApi;
|
||||
@@ -1096,11 +1095,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
|
||||
const paneHost = usePaneContext().host;
|
||||
const showsComposer = draft !== null || paneHost !== "sheet";
|
||||
const showsComposer = usePaneContext().host !== "sheet";
|
||||
// The dock lays out fullscreen for the same session the composer edits in.
|
||||
const composerEditingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(composerEditingSession);
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||
);
|
||||
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
@@ -1154,7 +1161,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const parentCallout = parentAgentId ? (
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
|
||||
) : null;
|
||||
const agentComposerSection = showsComposer ? (
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
@@ -1175,16 +1182,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
) : (
|
||||
parentCallout
|
||||
);
|
||||
const composerSection = draft ? (
|
||||
<DraftComposerSection
|
||||
serverId={serverId}
|
||||
draft={draft}
|
||||
isPaneFocused={isPaneFocused}
|
||||
editingSession={composerEditingSession}
|
||||
/>
|
||||
) : (
|
||||
agentComposerSection
|
||||
);
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
@@ -1194,7 +1191,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
agent={effectiveAgent}
|
||||
draft={draft}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
hasActiveComposer={hasActiveComposer}
|
||||
@@ -1202,12 +1198,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</RenderProfile>
|
||||
{hasActiveComposer && !draft ? (
|
||||
{hasActiveComposer ? (
|
||||
<AgentTracks
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
parentAgentId={parentPillAgentId}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -1263,7 +1263,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
</RewindComposerRestoreProvider>
|
||||
);
|
||||
return wrapAgentCommandProvider({
|
||||
enabled: showsComposer && hasActiveComposer && !draft,
|
||||
enabled: showsComposer && hasActiveComposer,
|
||||
serverId,
|
||||
agentId,
|
||||
editingSession: composerEditingSession,
|
||||
@@ -1355,7 +1355,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
serverId,
|
||||
agentId,
|
||||
agent,
|
||||
draft,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
hasActiveComposer,
|
||||
@@ -1365,8 +1364,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
agent: AgentScreenAgent | null;
|
||||
draft: DraftConversation | null;
|
||||
agent: AgentScreenAgent;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
hasActiveComposer: boolean;
|
||||
@@ -1424,31 +1422,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
}
|
||||
return new Map(pendingPermissionList.map((permission) => [permission.key, permission]));
|
||||
}, [pendingPermissionList]);
|
||||
// A draft's timeline exists only while its first prompt creates the agent. The created agent's
|
||||
// timeline then replaces this input in the same mounted view.
|
||||
const draftTimeline = useDraftTimeline({ serverId, draft });
|
||||
|
||||
if (draftTimeline) {
|
||||
return (
|
||||
<AgentStreamView
|
||||
ref={streamViewRef}
|
||||
agentId={draftTimeline.agent.id}
|
||||
serverId={serverId}
|
||||
context={draftTimeline.agent}
|
||||
streamItems={draftTimeline.streamItems}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
bottomOverlayTailClearance={bottomOverlayTailClearance}
|
||||
bottomOverlayControlClearance={bottomOverlayControlClearance}
|
||||
toast={toast}
|
||||
pendingMessageSubmissions={draftTimeline.pendingMessageSubmissions}
|
||||
turnPresentation={draftTimeline.turnPresentation}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AgentStreamView
|
||||
ref={streamViewRef}
|
||||
@@ -1469,44 +1443,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
);
|
||||
});
|
||||
|
||||
/** The draft's composer, which creates the agent and retargets this tab to it. */
|
||||
function DraftComposerSection({
|
||||
serverId,
|
||||
draft,
|
||||
isPaneFocused,
|
||||
editingSession,
|
||||
}: {
|
||||
serverId: string;
|
||||
draft: DraftConversation;
|
||||
isPaneFocused: boolean;
|
||||
editingSession: ComposerEditingSession;
|
||||
}) {
|
||||
const { openImportSheet, retargetCurrentTab } = usePaneContext();
|
||||
const handleCreated = useCallback(
|
||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
||||
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
},
|
||||
[retargetCurrentTab, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceDraftComposer
|
||||
serverId={serverId}
|
||||
workspaceId={draft.workspaceId}
|
||||
tabId={draft.tabId}
|
||||
draftId={draft.draftId}
|
||||
initialSetup={draft.setup}
|
||||
isPaneFocused={isPaneFocused}
|
||||
editingSession={editingSession}
|
||||
onCreated={handleCreated}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AgentComposerSection = memo(function AgentComposerSection({
|
||||
agentId,
|
||||
serverId,
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useSettings } from "@/hooks/use-settings";
|
||||
import { PluginComposerPills } from "@/plugins";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
useArchiveFinishedSubagents,
|
||||
type ArchiveFinishedStatus,
|
||||
useArchiveSubagent,
|
||||
useDetachSubagent,
|
||||
useSubagentSheetTab,
|
||||
useSubagentsForParent,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
@@ -35,21 +35,20 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
workspaceId,
|
||||
agentId,
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
onArchiveFinished,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
parentAgentId: string | null;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
onArchiveFinished: () => void;
|
||||
}): ReactElement {
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||
);
|
||||
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const { tabId } = usePaneContext();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
@@ -120,8 +119,8 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
sheetTabFor={sheetTabFor}
|
||||
onArchiveSubagent={archiveSubagent}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={onArchiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedStatus}
|
||||
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
||||
/>
|
||||
<PluginComposerPills
|
||||
|
||||
@@ -390,39 +390,4 @@ describe("AgentDirectoryReplica", () => {
|
||||
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
it("keeps unchanged agent fields when an update changes only the status", () => {
|
||||
const serverId = "agent-replica-unchanged-fields";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new AgentDirectoryReplica(
|
||||
serverId,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const agentInStore = () => useSessionStore.getState().sessions[serverId]?.agents.get("agent");
|
||||
replica.commitSnapshot([entry(payload("agent"))], []);
|
||||
const before = agentInStore();
|
||||
|
||||
replica.applyDelta({
|
||||
kind: "upsert",
|
||||
agent: { ...payload("agent"), status: "running", updatedAt: "2026-07-17T00:02:00.000Z" },
|
||||
project: entry(payload("agent")).project,
|
||||
});
|
||||
const afterDelta = agentInStore();
|
||||
replica.commitSnapshot(
|
||||
[entry({ ...payload("agent"), status: "idle", updatedAt: "2026-07-17T00:03:00.000Z" })],
|
||||
[],
|
||||
);
|
||||
const afterSnapshot = agentInStore();
|
||||
|
||||
expect(afterDelta?.status).toBe("running");
|
||||
expect(afterSnapshot?.status).toBe("idle");
|
||||
for (const agent of [afterDelta, afterSnapshot]) {
|
||||
expect(agent?.capabilities).toBe(before?.capabilities);
|
||||
expect(agent?.availableModes).toBe(before?.availableModes);
|
||||
expect(agent?.labels).toBe(before?.labels);
|
||||
expect(agent?.projectPlacement).toBe(before?.projectPlacement);
|
||||
}
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user