mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd845129e9 | ||
|
|
161076a853 | ||
|
|
14845d3ff3 | ||
|
|
616f5e155c | ||
|
|
471db4bdb5 | ||
|
|
68fc16b855 | ||
|
|
ae157dbfb6 | ||
|
|
dc51a3b6a9 | ||
|
|
77798b2e46 | ||
|
|
c8bd4717c9 | ||
|
|
6c1b48d296 | ||
|
|
1a626230d9 | ||
|
|
4b285a113d | ||
|
|
4281372bee |
+27
-3
@@ -259,6 +259,27 @@ 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
|
||||
@@ -313,10 +334,13 @@ Running provider-native subagents contribute `running` to the workspace owned by
|
||||
|
||||
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
|
||||
`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores
|
||||
its selected tab; attention on another agent remains unread until that agent is focused or the
|
||||
workspace is explicitly marked read.
|
||||
|
||||
Attention is set by the agent finishing or failing and cleared by the client's
|
||||
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
|
||||
Attention is set by the agent finishing or failing and acknowledged by deliberate focus or
|
||||
interaction with its chat, including repeat clicks in an already focused pane. A completion
|
||||
arriving while focused stays unread until the next interaction; leaving does not acknowledge it. Loading an agent's runtime is
|
||||
neither, so resuming carries the stored attention and the stored last-activity time through
|
||||
untouched. Forging either makes a background resume look like the user read a workspace and like the
|
||||
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
||||
|
||||
+2
-11
@@ -179,17 +179,8 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
|
||||
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
|
||||
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
|
||||
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
|
||||
lists the content-position corrections and the React Native issues they work around.
|
||||
|
||||
The timeline's `nativeID` (`agent-chat-scroll-native-virtualized`) also identifies it to the native
|
||||
scroll owner: a nested panel's edge overshoot and unconsumed fling must leave the timeline still.
|
||||
Ordinary scroll parents retain Android's handoff. Keep that identity in sync with
|
||||
`agent-stream/strategy-native.tsx`; the end pin is absent while reading history, so it cannot
|
||||
identify the timeline for this rule.
|
||||
|
||||
`VisibleContentPositionClampTest` mounts through React Native's own mounting manager;
|
||||
`PanelEdgeTest` covers both panel edges, flings, interior/direct scrolling, and ordinary-parent
|
||||
handoff with the real compiled scroll views:
|
||||
lists the corrections and the React Native issues they work around. Its instrumentation test,
|
||||
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
|
||||
+6
-2
@@ -68,7 +68,11 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -140,7 +144,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 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`).
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
||||
branch on its current route.
|
||||
|
||||
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
|
||||
Pass only `serverId` and `workspaceId` to restore the workspace’s selected tab.
|
||||
When the action names a specific tab, pass it as `target`; that explicit choice
|
||||
is authoritative. Callers should not choose between separate route and tab
|
||||
navigation APIs.
|
||||
|
||||
@@ -84,3 +84,10 @@ 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.
|
||||
|
||||
@@ -21,11 +21,14 @@ Which triggers keep terminals and which close them is owned by the stop reason (
|
||||
|
||||
## Record, socket and secret
|
||||
|
||||
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
|
||||
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its pid, protocol version, socket path, random secret, runtime copy and terminal programs. One daemon holds the home's pid lock. Its records and sockets belong to that home.
|
||||
|
||||
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
|
||||
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
|
||||
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
|
||||
- The first frame must be `hello` with the secret; anything else closes the connection. Anyone authenticated can type into every shell, so the record and Unix socket are `0600` inside a `0700` directory.
|
||||
- New socket names include the record id. A stale record whose pid belongs to a newer worker cannot reach that newer worker. Existing records keep their recorded paths. Legacy paths use only the home and pid, so force-killing through one also requires successful authentication. A legacy socket that cannot authenticate stays unconfirmed.
|
||||
- A missing pid or refused socket retires the record without signaling the worker. An accepting socket identifies the live worker. Cleanup asks it to shut down, then probes again before force-killing it after the grace period (`taskkill /T` on Windows). An inconclusive probe counts toward the worker limit and is retried on the next start or stop without signaling it.
|
||||
- Workers never query their own birth time or command line. Readers accept legacy identity fields but ignore them. Daemons through 0.15.1 report new records unreadable and cannot adopt them.
|
||||
- Windows terminal programs have no recorded birth time and terminal lifecycle operations never launch PowerShell. Linux and macOS retain program birth-time checks through `ps` with `LC_ALL=C`; Linux also walks sessions to find surviving jobs.
|
||||
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Long dev homes use an owner-only fallback under `$TMPDIR/paseo-<uid>/` or `/tmp/paseo-<uid>/`. An existing directory must be a real `0700` directory owned by you; a shared root is never chmodded.
|
||||
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
|
||||
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
|
||||
|
||||
@@ -40,7 +43,7 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
|
||||
| two or more older | shuts it down; its terminals close (logged) |
|
||||
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
|
||||
|
||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable worker counts, as does one whose socket probe is inconclusive, and one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||
|
||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ test.describe("Agent timeline sync retry", () => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 2 });
|
||||
try {
|
||||
gate.holdNextClientRequest("clear_agent_attention");
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
gate.failTimelineResponses(agent.agentId);
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { writeFile, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
expectNoBranchSwitcherInWorkspaceHeader,
|
||||
@@ -14,6 +17,88 @@ import {
|
||||
waitForSidebarHydration,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
// Hold the real daemon response until the test has observed the pending UI.
|
||||
async function holdBranchSuggestions(page: Page) {
|
||||
let requestCount = 0;
|
||||
let release!: () => void;
|
||||
let gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const envelope = JSON.parse(message.toString());
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||
requestCount++;
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage(async (message) => {
|
||||
const envelope = JSON.parse(message.toString());
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") {
|
||||
await gate;
|
||||
}
|
||||
browser.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
release: () => release(),
|
||||
holdNextResponse: () => {
|
||||
gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
requestCount: () => requestCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
|
||||
await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click();
|
||||
const picker = page.getByTestId("combobox-desktop-container");
|
||||
await expect(picker.getByText("Loading...", { exact: true })).toBeVisible();
|
||||
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
||||
release();
|
||||
await expect(picker.getByText("dev", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(picker).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function expectFailedRefreshRecovery(
|
||||
page: Page,
|
||||
repoPath: string,
|
||||
responses: Awaited<ReturnType<typeof holdBranchSuggestions>>,
|
||||
) {
|
||||
// Expire the actual query freshness window before reopening the cached picker.
|
||||
const started = Date.now();
|
||||
await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000);
|
||||
const picker = page.getByTestId("combobox-desktop-container");
|
||||
const packedRefs = path.join(repoPath, ".git", "packed-refs");
|
||||
await writeFile(packedRefs, "invalid packed refs\n");
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Current branch: dev. Press to switch branch." })
|
||||
.click();
|
||||
await expect(picker.getByRole("alert")).toHaveText(
|
||||
"Could not load branches. Reopen the picker to try again.",
|
||||
);
|
||||
} finally {
|
||||
await unlink(packedRefs);
|
||||
}
|
||||
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
||||
responses.holdNextResponse();
|
||||
const beforeRetry = responses.requestCount();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click();
|
||||
await expect(picker.getByRole("alert")).toHaveText("Loading...");
|
||||
await expect.poll(responses.requestCount).toBe(beforeRetry + 1);
|
||||
await expect(picker).toBeVisible();
|
||||
responses.release();
|
||||
await expect(picker.getByRole("alert")).toHaveCount(0);
|
||||
await expect(picker.getByText("main", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
expect(responses.requestCount()).toBe(beforeRetry + 1);
|
||||
}
|
||||
|
||||
async function renameWorkspaceViaSidebar(
|
||||
page: Page,
|
||||
input: { workspaceId: string; title: string },
|
||||
@@ -59,6 +144,7 @@ test.describe("Branch switcher", () => {
|
||||
try {
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
|
||||
const releaseBranches = await holdBranchSuggestions(page);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
||||
@@ -82,8 +168,10 @@ test.describe("Branch switcher", () => {
|
||||
// and switching it checks out the real branch on disk.
|
||||
await openChangesPanel(page);
|
||||
await expectWorkspaceBranch(page, "main");
|
||||
await expectInitialBranchLoading(page, releaseBranches.release);
|
||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches);
|
||||
|
||||
// The custom title is unaffected by the branch switch.
|
||||
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
||||
|
||||
@@ -427,6 +427,15 @@ test("Changes shows the base as text on a host without the base-branch capabilit
|
||||
expect(baseRequests).toBe(0);
|
||||
});
|
||||
|
||||
test("Changes shows no base on a checkout that is not a Paseo worktree", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const header = page.getByTestId("changes-repository-header").filter({ visible: true });
|
||||
await expect(header).toContainText("feature");
|
||||
await expect(header).not.toContainText("main");
|
||||
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
@@ -105,7 +105,21 @@ interface PopoverFrameRecorderWindow extends Window {
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
interface ListCommandsReply {
|
||||
commands: readonly { name: string; description: string; argumentHint: string }[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null };
|
||||
|
||||
/**
|
||||
* Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds
|
||||
* the response until it settles, standing in for a slow host.
|
||||
*/
|
||||
async function installListCommandsStub(
|
||||
page: Page,
|
||||
reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => TEST_COMMANDS_REPLY,
|
||||
): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
@@ -130,14 +144,20 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
||||
};
|
||||
};
|
||||
};
|
||||
const payload = parsed.message?.payload;
|
||||
if (
|
||||
parsed.type === "session" &&
|
||||
parsed.message?.type === "list_commands_response" &&
|
||||
parsed.message.payload
|
||||
payload
|
||||
) {
|
||||
parsed.message.payload.commands = TEST_COMMANDS;
|
||||
parsed.message.payload.error = null;
|
||||
ws.send(JSON.stringify(parsed));
|
||||
const send = (result: ListCommandsReply) => {
|
||||
payload.commands = result.commands;
|
||||
payload.error = result.error;
|
||||
ws.send(JSON.stringify(parsed));
|
||||
};
|
||||
const result = reply();
|
||||
if (result instanceof Promise) void result.then(send);
|
||||
else send(result);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -149,6 +169,17 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function createHeldReply(): {
|
||||
reply: () => Promise<ListCommandsReply>;
|
||||
release: () => void;
|
||||
} {
|
||||
let release = () => {};
|
||||
const held = new Promise<ListCommandsReply>((resolve) => {
|
||||
release = () => resolve(TEST_COMMANDS_REPLY);
|
||||
});
|
||||
return { reply: () => held, release };
|
||||
}
|
||||
|
||||
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||
@@ -545,16 +576,17 @@ test.describe("Composer autocomplete", () => {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/he");
|
||||
// `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows.
|
||||
await input.fill("/hea");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const beforeDelete = await visiblePopoverBox(page);
|
||||
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/h");
|
||||
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
|
||||
await expect(input).toHaveValue("/he");
|
||||
await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const afterDelete = await visiblePopoverBox(page);
|
||||
@@ -733,6 +765,116 @@ test.describe("Composer autocomplete", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("forgets an arrow-key choice once the search changes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-stale-choice-",
|
||||
title: "Autocomplete stale choice",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Down wraps from the default bottom row to the top: /clear, then /exit.
|
||||
await input.press("ArrowDown");
|
||||
await input.press("ArrowDown");
|
||||
await input.press("h");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveValue("/tdd ");
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows loading inside the popover until a slow host answers", async ({ page }) => {
|
||||
const host = createHeldReply();
|
||||
await installListCommandsStub(page, host.reply);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill("/");
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const loading = await visiblePopoverBox(page);
|
||||
|
||||
host.release();
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0);
|
||||
const results = await visiblePopoverBox(page);
|
||||
|
||||
expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({
|
||||
page,
|
||||
}) => {
|
||||
let reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => ({
|
||||
commands: [],
|
||||
error: "Timed out waiting for the agent session",
|
||||
});
|
||||
await installListCommandsStub(page, () => reply());
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-failing-host-",
|
||||
title: "Autocomplete failing host",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill("/");
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
const errorRow = popover.getByTestId("composer-autocomplete-error");
|
||||
await expect(errorRow).toContainText("Timed out waiting for the agent session", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await input.press("Enter");
|
||||
await page.waitForTimeout(500);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
|
||||
await input.fill("/");
|
||||
await expect(errorRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const host = createHeldReply();
|
||||
reply = host.reply;
|
||||
await errorRow.getByRole("button", { name: "Retry" }).click();
|
||||
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible();
|
||||
|
||||
host.release();
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("compact sidebar layering", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
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";
|
||||
@@ -124,3 +130,128 @@ test.describe("Composer controls folding on a touch device", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Folded model control on a phone", () => {
|
||||
// Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit.
|
||||
test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("hides the thinking label before the model label truncates", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-density-labels-",
|
||||
title: "Label priority chat",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await recordModelTriggerFramesFromLoad(page);
|
||||
await openAgentRoute(page, session);
|
||||
await expect(
|
||||
page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }),
|
||||
).toHaveText("Low");
|
||||
await readSettledModelTriggerLabels(page);
|
||||
// From the first frame on, the control never guesses: it paints both labels only once it
|
||||
// has measured that they fit.
|
||||
await expectNoFrameTruncatesModelBesideThinking(page);
|
||||
|
||||
const sweep = await sweepModelTriggerLabels(page, {
|
||||
from: 480,
|
||||
to: 280,
|
||||
step: 4,
|
||||
height: 844,
|
||||
});
|
||||
|
||||
expect(sweep.widest).toMatchObject({
|
||||
modelText: "Ten second stream",
|
||||
thinkingShown: true,
|
||||
modelTruncated: false,
|
||||
});
|
||||
expect(sweep.thinkingHidden).toMatchObject({
|
||||
modelText: "Ten second stream",
|
||||
modelTruncated: false,
|
||||
});
|
||||
expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width);
|
||||
// A clean tail: the label ends on a whole word and the ellipsis, never on a space.
|
||||
expect(sweep.modelTruncated).toMatchObject({
|
||||
modelText: "Ten second…",
|
||||
thinkingShown: false,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Composer toolbar rails on touch", () => {
|
||||
test.use({ isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
for (const viewport of [
|
||||
{ device: "phone", width: 390, height: 844 },
|
||||
{ device: "tablet", width: 1024, height: 1366 },
|
||||
]) {
|
||||
test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
const idle = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-rails-idle-",
|
||||
title: "Rails idle chat",
|
||||
});
|
||||
let running: Awaited<ReturnType<typeof seedMockAgentWorkspace>> | null = null;
|
||||
try {
|
||||
await openAgentRoute(page, idle);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await test.step("empty composer: attach on the left, voice mode on the right", async () => {
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
await test.step("draft: the send disc on the right", async () => {
|
||||
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||
await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible();
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
|
||||
running = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-rails-running-",
|
||||
title: "Rails running chat",
|
||||
model: "one-minute-stream",
|
||||
initialPrompt: "stream for a while",
|
||||
});
|
||||
await openAgentRoute(page, running);
|
||||
await test.step("running agent: the stop disc on the right", async () => {
|
||||
await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
} finally {
|
||||
await idle.cleanup();
|
||||
await running?.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Composer trailing disc on a new chat", () => {
|
||||
test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } });
|
||||
|
||||
test("an empty draft holds a disabled send disc until there is something to send", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
const send = page
|
||||
.getByTestId("message-input-root")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Send message", exact: true });
|
||||
await expect(send).toBeDisabled({ timeout: 30_000 });
|
||||
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||
await expect(send).toBeEnabled();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import type { CDPSession, Locator } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedSubagent } from "../support/helpers/subagents";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -860,3 +862,91 @@ async function expectReviewEditorBody(page: Page, body: string) {
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
async function swipeComposerTracks(page: Page, client: CDPSession, direction: "left" | "right") {
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
const box = await track.boundingBox();
|
||||
if (!box) throw new Error("Composer tracks are not visible");
|
||||
const start = direction === "left" ? box.x + box.width - 5 : box.x + 5;
|
||||
const end = direction === "left" ? box.x + 5 : box.x + box.width - 5;
|
||||
await swipeTouch(client, start, end, box.y + box.height / 2);
|
||||
}
|
||||
|
||||
async function swipeTouch(client: CDPSession, start: number, end: number, y: number) {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: start, y }],
|
||||
});
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: start + ((end - start) * step) / 8, y }],
|
||||
});
|
||||
}
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
}
|
||||
|
||||
async function trackScrollOffset(track: Locator): Promise<number> {
|
||||
return track.evaluate((node) => node.scrollLeft);
|
||||
}
|
||||
|
||||
test.describe("touch composer tracks", () => {
|
||||
test.use({ hasTouch: true, isMobile: true });
|
||||
test("compact composer scrolls its task, subagent and changes tracks without squeezing them", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("composer-tracks-overflow-");
|
||||
const touch = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Track child" },
|
||||
);
|
||||
await page.setViewportSize({ width: 1000, height: 844 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await submitMessage(page, "emit 1 agent stream updates");
|
||||
const tasks = page.getByTestId("agent-task-list-header");
|
||||
const subagents = page.getByTestId("subagents-track-header");
|
||||
const changes = page.getByTestId("composer-diff-stat-pill");
|
||||
await expect(tasks).toBeVisible();
|
||||
await expect(subagents).toBeVisible();
|
||||
await expect(changes).toBeVisible();
|
||||
const wideWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
const narrowWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
for (const [index, width] of wideWidths.entries())
|
||||
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
);
|
||||
await swipeComposerTracks(page, touch, "left");
|
||||
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
|
||||
await swipeComposerTracks(page, touch, "right");
|
||||
await expect.poll(() => trackScrollOffset(track)).toBeLessThanOrEqual(1);
|
||||
await swipeComposerTracks(page, touch, "left");
|
||||
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport();
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1 });
|
||||
await page.getByTestId("sidebar-close").click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
|
||||
await expect(changes).toBeInViewport();
|
||||
await changes.click();
|
||||
await expect(
|
||||
page.getByTestId("changes-header").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await touch.detach();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
@@ -14,7 +15,11 @@ import {
|
||||
expectTaskListEntries,
|
||||
openCompletedTaskList,
|
||||
} from "../support/helpers/task-list";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { submitMessage, submitMessageWithButton } from "../support/helpers/composer";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
|
||||
interface TaskScenario {
|
||||
provider: RewindFlowProvider;
|
||||
@@ -114,3 +119,425 @@ test.describe("real provider composer task lists", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openBackgroundInventory(
|
||||
page: Page,
|
||||
options: { compact: boolean; count: number; timeout: number },
|
||||
): Promise<Locator> {
|
||||
const pill = page.getByRole("button", {
|
||||
name: options.count === 1 ? "1 background task" : `${options.count} background tasks`,
|
||||
});
|
||||
await expect(pill).toBeVisible({ timeout: options.timeout });
|
||||
await pill.click();
|
||||
const panel = page.getByTestId(
|
||||
options.compact ? "background-work-panel-content" : "background-work-panel",
|
||||
);
|
||||
if (options.compact) await waitForSettledPosition(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function openBackgroundWork(page: Page, compact: boolean): Promise<void> {
|
||||
const panel = await openBackgroundInventory(page, { compact, count: 1, timeout: 30_000 });
|
||||
await expect(panel).toContainText(/sleep 120/);
|
||||
}
|
||||
|
||||
async function expectBackgroundItems(
|
||||
panel: Locator,
|
||||
selected: string,
|
||||
sibling: string,
|
||||
): Promise<void> {
|
||||
await expect(panel).toContainText(selected);
|
||||
await expect(panel).toContainText(sibling);
|
||||
}
|
||||
|
||||
async function stopBackgroundItem(panel: Locator, selected: string): Promise<void> {
|
||||
const escaped = selected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await panel.getByRole("button", { name: new RegExp(`Stop .*${escaped}`) }).click();
|
||||
}
|
||||
|
||||
async function expectStoppedItemAndSibling(
|
||||
panel: Locator,
|
||||
selected: string,
|
||||
sibling: string,
|
||||
): Promise<void> {
|
||||
await expect(panel).not.toContainText(selected, { timeout: 40_000 });
|
||||
await expect(panel).toContainText(sibling);
|
||||
}
|
||||
|
||||
async function closeBackgroundWork(page: Page, compact: boolean): Promise<void> {
|
||||
const panel = page.getByTestId(
|
||||
compact ? "background-work-panel-content" : "background-work-panel",
|
||||
);
|
||||
if (compact) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.click({ position: { x: 12, y: 12 } });
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
|
||||
async function expectBackgroundAgentRunning(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-status-indicator-running").first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectAssistantReply(page: Page, text: string): Promise<void> {
|
||||
await expect(page.getByTestId("assistant-message").filter({ hasText: text }).last()).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectBackgroundPill(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "1 background task" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectBackgroundWorkOnAnotherClient(page: Page): Promise<void> {
|
||||
const another = await page.context().newPage();
|
||||
try {
|
||||
await another.goto(page.url());
|
||||
await openBackgroundWork(another, false);
|
||||
} finally {
|
||||
await another.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function startBackgroundWorkWithoutOpeningPane(workspace: SeededWorkspace): Promise<void> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: workspace.workspaceDirectory,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Background work before opening",
|
||||
});
|
||||
await workspace.client.sendAgentMessage(
|
||||
agent.id,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
const result = await workspace.client.waitForFinish(agent.id, 120_000);
|
||||
expect(result.status).toBe("idle");
|
||||
}
|
||||
|
||||
async function expectWorkspaceRunning(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row.getByTestId("workspace-status-indicator-running")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test("Claude keeps background work visible while accepting another turn", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-work-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase1-claude-background.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, false);
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
await expectBackgroundWorkOnAnotherClient(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude background work remains readable in dark mode", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
|
||||
});
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-dark-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/review-background-dark.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude background work opens in a compact sheet", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-compact-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectAssistantReply(page, "SPAWNED");
|
||||
await openBackgroundWork(page, true);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase1-claude-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, true);
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
"Reply exactly HELLO without stopping the background command.",
|
||||
);
|
||||
await expectAssistantReply(page, "HELLO");
|
||||
await expectBackgroundPill(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("background work updates the sidebar before the agent is opened", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "background-unopened-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`),
|
||||
).toBeVisible();
|
||||
await startBackgroundWorkWithoutOpeningPane(workspace);
|
||||
await expectWorkspaceRunning(page, workspace.workspaceId);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex keeps a live command visible after its turn", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-background-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "gpt-6-astra" },
|
||||
});
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use exec_command to run sleep 120 with yield_time_ms 1000. Do not wait for or stop it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase2-codex-background.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, false);
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
await expectBackgroundWorkOnAnotherClient(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode keeps a live command visible after its turn", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-opencode-background-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
|
||||
});
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use the shell tool with background true to run sleep 120. Do not wait or stop it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase2-opencode-background.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, false);
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
await expectBackgroundWorkOnAnotherClient(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
provider: "claude" as const,
|
||||
prompt:
|
||||
"Use Bash with run_in_background to start sleep 180 and sleep 181 as two separate background commands. Do not wait for them. Reply SPAWNED immediately.",
|
||||
providerConfig: { model: "haiku" },
|
||||
},
|
||||
{
|
||||
provider: "codex" as const,
|
||||
prompt:
|
||||
"Use exec_command twice to start sleep 180 and sleep 181 separately with yield_time_ms 1000. Do not wait for or stop either process. Reply SPAWNED immediately.",
|
||||
providerConfig: { model: "gpt-6-astra" },
|
||||
},
|
||||
{
|
||||
provider: "opencode" as const,
|
||||
prompt:
|
||||
"Use the shell tool twice with background true: start sleep 180, then start sleep 181. Do not wait for or stop either. Reply SPAWNED immediately.",
|
||||
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
|
||||
},
|
||||
]) {
|
||||
test(`${scenario.provider} stops selected background work after the turn`, async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
|
||||
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-qa-workspace-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await sendMessage(handle, scenario.prompt);
|
||||
const panel = await openBackgroundInventory(page, {
|
||||
compact: false,
|
||||
count: 2,
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expectBackgroundItems(panel, "sleep 180", "sleep 181");
|
||||
await stopBackgroundItem(panel, "sleep 180");
|
||||
await expectStoppedItemAndSibling(panel, "sleep 180", "sleep 181");
|
||||
await page.screenshot({
|
||||
path: path.resolve(`../../.artifacts/phase3-${scenario.provider}-stop.png`),
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Claude stops a selected monitor in the compact sheet", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
|
||||
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-monitor-workspace-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opus" },
|
||||
});
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
'Use exactly two separate Monitor tool calls, each with command `sleep 150`, timeout_ms 150000, persistent false, descriptions "probe monitor one" and "probe monitor two". Launch both independently, do not wait, then reply SPAWNED. Do not use any other tools.',
|
||||
);
|
||||
await expectAssistantReply(page, "SPAWNED");
|
||||
const panel = await openBackgroundInventory(page, { compact: true, count: 2, timeout: 30_000 });
|
||||
await expectBackgroundItems(panel, "probe monitor one", "probe monitor two");
|
||||
await expect(panel.getByText("sleep 150", { exact: true })).toHaveCount(2);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/refinement-monitor-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await stopBackgroundItem(panel, "probe monitor one");
|
||||
await expectStoppedItemAndSibling(panel, "probe monitor one", "probe monitor two");
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase3-claude-monitor-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, true);
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
"Reply exactly HELLO without stopping the background monitor.",
|
||||
);
|
||||
await expectAssistantReply(page, "HELLO");
|
||||
await expectBackgroundPill(page);
|
||||
await handle.client.sendAgentMessage(
|
||||
handle.agentId,
|
||||
"Use Bash to run sleep 20 in the foreground, then reply WAITED.",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await handle.client.sendAgentMessage(
|
||||
handle.agentId,
|
||||
"Reply exactly INTERRUPTED without stopping the background monitor.",
|
||||
{ activeTurnBehavior: "interrupt" },
|
||||
);
|
||||
expect((await handle.client.waitForFinish(handle.agentId, 120_000)).status).toBe("idle");
|
||||
await expectBackgroundPill(page);
|
||||
await expect
|
||||
.poll(async () => handle!.client.listAgentBackgroundWork(handle!.agentId))
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ description: "probe monitor two", kind: "monitor" }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude shows monitor description and command in the wide track", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.resolve("../../.artifacts/refinement-wide-workspace-")),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opus" },
|
||||
});
|
||||
await sendMessage(
|
||||
handle,
|
||||
'Use Monitor with command `sleep 150`, timeout_ms 150000, persistent false, description "Watch the build". Do not wait; reply SPAWNED. Do not use other tools.',
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${handle.workspaceId}`,
|
||||
);
|
||||
await workspaceRow.scrollIntoViewIfNeeded();
|
||||
await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toBeVisible();
|
||||
const panel = await openBackgroundInventory(page, {
|
||||
compact: false,
|
||||
count: 1,
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(panel).toContainText("Watch the build");
|
||||
await expect(panel).toContainText("sleep 150");
|
||||
await expect(panel.getByRole("button", { name: "Stop Watch the build" })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/refinement-monitor-wide.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { expectOpenTabCount } from "../support/helpers/sheet";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { expectReconnectingToastGone } from "../support/helpers/workspace-ui";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
@@ -101,11 +102,13 @@ async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: Fin
|
||||
}
|
||||
|
||||
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
@@ -141,13 +144,15 @@ async function leaveMarkedWorkspaceAndReopenOnCompact(
|
||||
}
|
||||
|
||||
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
|
||||
await closeMobileAgentSidebar(page);
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
@@ -194,11 +199,12 @@ async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
|
||||
test("manual unread survives departure and clears on reopening and ordinary completion survives departure", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await leaveMarkedWorkspaceAndRead(page, workspaces);
|
||||
@@ -207,16 +213,19 @@ test("manual unread survives departure and clears on reopening without changing
|
||||
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
|
||||
});
|
||||
|
||||
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
|
||||
test("clicking a multi-agent workspace preserves its selected agent and leaves the other unread", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await expectSelectedAgent(page, workspaces.subject.agentId);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
const newest = await addFinishedAgent(workspaces.subject);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await expectSelectedAgent(page, newest.id);
|
||||
await addFinishedAgent(workspaces.subject);
|
||||
await markAsUnread(page, workspaces.subject.workspaceId);
|
||||
await openWorkspace(page, workspaces.subject.workspaceId);
|
||||
await expectSelectedAgent(page, workspaces.subject.agentId);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
});
|
||||
|
||||
test("manual unread survives leaving the current workspace on compact layout", async ({
|
||||
@@ -230,7 +239,7 @@ test("manual unread survives leaving the current workspace on compact layout", a
|
||||
await completeTurnAndLeaveOnCompact(page, workspaces);
|
||||
});
|
||||
|
||||
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
|
||||
async function reopenWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
|
||||
const route = page.url();
|
||||
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
|
||||
await page.goto("about:blank");
|
||||
@@ -265,9 +274,72 @@ test("reopening a marked unread workspace restores only its agent tab and curren
|
||||
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
|
||||
await openMobileAgentSidebar(page);
|
||||
await markAsUnread(page, workspaces.subject.workspaceId);
|
||||
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
});
|
||||
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
|
||||
await expectRestoredCompactChat(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking the already focused timeline acknowledges a new completion", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await workspaces.subject.client.sendAgentMessage(
|
||||
workspaces.subject.agentId,
|
||||
"Finish another turn.",
|
||||
);
|
||||
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await page.getByText("Finish another turn.", { exact: true }).click();
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
});
|
||||
|
||||
async function reconnectWithoutInteracting(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
agentId: string,
|
||||
) {
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
gate.holdTimelineResponses(agentId);
|
||||
gate.restore();
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await expectReconnectingToastGone(page);
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("reconnecting a focused chat preserves its unread completion until another click", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await workspaces.subject.client.sendAgentMessage(
|
||||
workspaces.subject.agentId,
|
||||
"Unread across reconnect.",
|
||||
);
|
||||
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await page.getByText("Unread across reconnect.", { exact: true }).click();
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
});
|
||||
|
||||
test("a cached read chat acknowledges a completion that arrived while the app was closed", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openCompactWorkspace(page, workspaces.subject);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
await expectRestoredCompactChat(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getActiveTabTestId } from "../support/helpers/launcher";
|
||||
import type { Page, Locator } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { withDirectoryPlugin } from "../support/helpers/plugin-timeline";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.use({
|
||||
@@ -50,15 +51,16 @@ async function seedAnsweredAgent(title: string) {
|
||||
}
|
||||
|
||||
/** Chat rows between the latest turn's summary line and its reply, in document order. */
|
||||
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
async function rowsBetweenLatestSummaryAndReply(
|
||||
page: Page,
|
||||
replyTestId = "assistant-message",
|
||||
): Promise<number> {
|
||||
return page.evaluate((testId) => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const replyRow = rows.findLastIndex((row) =>
|
||||
row.querySelector("[data-testid=assistant-message]"),
|
||||
);
|
||||
const replyRow = rows.findLastIndex((row) => row.querySelector(`[data-testid="${testId}"]`));
|
||||
return replyRow - summaryRow - 1;
|
||||
});
|
||||
}, replyTestId);
|
||||
}
|
||||
|
||||
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
|
||||
@@ -126,6 +128,67 @@ async function expandAndCollapseLongPrompt(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("Turn collapse", () => {
|
||||
test("folds work before a plugin-rendered reply and reopens it", async ({ page }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "plugin-reply-fold-",
|
||||
title: "Plugin reply folding",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Make the fix and run the burst script.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await withDirectoryPlugin({
|
||||
pluginId: "reply-fold-e2e",
|
||||
source: `import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addTimelineTransformer({
|
||||
id: "reply", query: { itemType: "assistant_message" },
|
||||
transform({ item, phase }) {
|
||||
if (phase === "streaming") return undefined;
|
||||
return { items: [{ type: "plugin", kind: "reply", version: 1, data: { text: item.text } }] };
|
||||
},
|
||||
});
|
||||
client.addTimelineRenderer({
|
||||
kind: "reply", version: 1, schema: z.object({ text: z.string() }),
|
||||
Component: ({ item }) => <Text testID="plugin-reply">{item.data.text}</Text>,
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
run: async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
for (const width of [1280, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
const reply = page.getByTestId("plugin-reply").last();
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(reply).toContainText("(end of synthetic stream)");
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
|
||||
await page.screenshot({ path: info.outputPath(`plugin-reply-folded-${width}.png`) });
|
||||
await summary.click();
|
||||
await expect
|
||||
.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply"))
|
||||
.toBeGreaterThan(0);
|
||||
await expect(reply).toContainText("(end of synthetic stream)");
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
|
||||
}
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("turn-summary").last()).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -509,6 +509,8 @@ check_autocomplete() {
|
||||
fi
|
||||
fi
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
# Rows replace the popover's loading row once the host lists them; a cold Claude list is slow.
|
||||
ad wait "text=\"${expected}\"" 30000
|
||||
# The popover remeasures once keyboard motion settles.
|
||||
sleep 1
|
||||
capture_screen "${prefix}.png"
|
||||
|
||||
@@ -69,17 +69,23 @@ async function waitFor(name, predicate, timeoutMs = 10_000) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard motion has settled once two snapshots agree on the composer position.
|
||||
// Keyboard motion has settled once two snapshots agree on the composer position. The
|
||||
// popover shows a loading row until the host lists its commands; a cold Claude list is slow.
|
||||
async function settledPlacement(name, keyboard) {
|
||||
let previousTop;
|
||||
return waitFor(name, (snap) => {
|
||||
const composer = nodeById(snap, "message-input-root");
|
||||
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
|
||||
if (keyboardShown(snap) !== keyboard) return false;
|
||||
const stable = composer.rect.y === previousTop;
|
||||
previousTop = composer.rect.y;
|
||||
return stable;
|
||||
});
|
||||
return waitFor(
|
||||
name,
|
||||
(snap) => {
|
||||
const composer = nodeById(snap, "message-input-root");
|
||||
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
|
||||
if (nodeById(snap, "composer-autocomplete-loading")) return false;
|
||||
if (keyboardShown(snap) !== keyboard) return false;
|
||||
const stable = composer.rect.y === previousTop;
|
||||
previousTop = composer.rect.y;
|
||||
return stable;
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
}
|
||||
|
||||
function assertPlacement(name, snap, expected) {
|
||||
|
||||
@@ -53,3 +53,192 @@ 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);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function contribute(client) {
|
||||
}`;
|
||||
}
|
||||
|
||||
async function withDirectoryPlugin(options: {
|
||||
export async function withDirectoryPlugin(options: {
|
||||
pluginId: string;
|
||||
source: string;
|
||||
run: () => Promise<void>;
|
||||
|
||||
@@ -157,8 +157,11 @@ export interface SeedDaemonClient {
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { sourceAgentId?: string },
|
||||
options?: { sourceAgentId?: string; activeTurnBehavior?: "interrupt" | "steer" | "queue" },
|
||||
): Promise<void>;
|
||||
listAgentBackgroundWork(
|
||||
agentId: string,
|
||||
): Promise<import("@getpaseo/client").PaseoAgentBackgroundWorkItem[]>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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"),
|
||||
};
|
||||
}
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.react.uimanager.DisplayMetricsHolder
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.views.scroll.ReactScrollView
|
||||
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||
import com.facebook.react.views.view.ReactViewGroup
|
||||
import com.facebook.soloader.SoLoader
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Forward-order parent, as used by the current Android timeline, and a nested panel at its edge. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PanelEdgeTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
private lateinit var manager: ReactScrollViewManager
|
||||
|
||||
@After fun finish() {
|
||||
instrumentation.runOnMainSync { activity?.finish() }
|
||||
}
|
||||
|
||||
@Test fun panelBottomEdgeLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Panel stays at its bottom", 600, child.scrollY)
|
||||
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelTopEdgeLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 0)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, -100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Panel stays at its top", 0, child.scrollY)
|
||||
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelFlingLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedFling(0f, 1500f, false)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync {
|
||||
assertEquals("Panel fling must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun disablingChildNestedScrollPreventsHandoff() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.isNestedScrollingEnabled = false
|
||||
assertEquals(false, child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
assertEquals(400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesNestedOvershoot() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
manager.setNativeId(parent, "ordinary-scroll-parent")
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Ordinary parent retains framework handoff", 500, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesUnconsumedFling() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
manager.setNativeId(parent, "ordinary-scroll-parent")
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedFling(0f, 1500f, false)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync { assertTrue(parent.scrollY > 400) }
|
||||
}
|
||||
|
||||
@Test fun consumedPanelFlingDoesNotStartParentFling() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
assertEquals(false, child.dispatchNestedFling(0f, 1500f, true))
|
||||
child.stopNestedScroll()
|
||||
assertEquals(400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelInteriorScrollingLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 100)
|
||||
assertEquals(100, child.scrollY)
|
||||
assertEquals(400, parent.scrollY)
|
||||
parent.scrollTo(0, 500)
|
||||
assertEquals("Direct timeline scrolling remains available", 500, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPanel(): Pair<ReactScrollView, ReactScrollView> {
|
||||
val started = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = started
|
||||
lateinit var parent: ReactScrollView
|
||||
lateinit var child: ReactScrollView
|
||||
instrumentation.runOnMainSync {
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
|
||||
SoLoader.init(started, OpenSourceMergedSoMapping)
|
||||
val react = ScrollTestContext(started.applicationContext)
|
||||
val context = ThemedReactContext(react, started)
|
||||
manager = PaseoScrollPackage().createViewManager(react, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
parent = manager.createViewInstance(context)
|
||||
manager.setNativeId(parent, "agent-chat-scroll-native-virtualized")
|
||||
child = manager.createViewInstance(context)
|
||||
child.isNestedScrollingEnabled = true
|
||||
val content = ReactViewGroup(context)
|
||||
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 400 })
|
||||
parent.addView(content)
|
||||
val childContent = ReactViewGroup(context)
|
||||
child.addView(childContent)
|
||||
started.setContentView(FrameLayout(started).apply { addView(parent, FrameLayout.LayoutParams(300, 400)) })
|
||||
parent.layout(0, 0, 300, 400)
|
||||
content.layout(0, 0, 300, 2000)
|
||||
child.layout(0, 400, 300, 600)
|
||||
childContent.layout(0, 0, 300, 800)
|
||||
parent.scrollTo(0, 400)
|
||||
}
|
||||
return Pair(parent, child)
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||
|
||||
/** Native-only tests use RN's own event sink for a context without JavaScript. */
|
||||
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||
ScrollTestContext(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventDispatcher getEventDispatcher() {
|
||||
return BlackHoleEventDispatcher.INSTANCE;
|
||||
}
|
||||
}
|
||||
+1
-24
@@ -12,13 +12,10 @@ import com.facebook.react.views.scroll.ReactScrollView;
|
||||
import com.facebook.react.views.scroll.ReactScrollViewHelper;
|
||||
|
||||
/**
|
||||
* React Native's vertical scroll view with timeline corrections. Java, because the content-position
|
||||
* React Native's vertical scroll view with two corrections. Java, because the content-position
|
||||
* config type is internal to React Native's Kotlin module.
|
||||
*/
|
||||
final class PaseoScrollView extends ReactScrollView {
|
||||
// The chat timeline, packages/app/src/agent-stream/strategy-native.tsx.
|
||||
private static final String TIMELINE_NATIVE_ID = "agent-chat-scroll-native-virtualized";
|
||||
|
||||
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
|
||||
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
|
||||
|
||||
@@ -35,26 +32,6 @@ final class PaseoScrollView extends ReactScrollView {
|
||||
super(context);
|
||||
}
|
||||
|
||||
// A panel owns its gesture through its edges; remaining child motion must not move the chat
|
||||
// behind it. The timeline's native identity stays present when its end pin is removed for
|
||||
// reading history. Ordinary parents retain Android's nested-scroll handoff.
|
||||
@Override
|
||||
public void onNestedScroll(
|
||||
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
|
||||
if (isTimeline()) return;
|
||||
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
|
||||
if (isTimeline() && !consumed) return true;
|
||||
return super.onNestedFling(target, velocityX, velocityY, consumed);
|
||||
}
|
||||
|
||||
private boolean isTimeline() {
|
||||
return TIMELINE_NATIVE_ID.equals(getTag(R.id.view_tag_native_id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaintainVisibleContentPosition(
|
||||
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
|
||||
|
||||
@@ -54,6 +54,28 @@ using namespace facebook::react;
|
||||
[[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self];
|
||||
}
|
||||
|
||||
// iOS 26 blurs a scroll view's edges and places the blur in the scroll view's own coordinates,
|
||||
// ignoring transforms. The keyboard lifts the chat with a translateY, so on iPhones the blur lands
|
||||
// over the readable chat and stays until the next scroll (#7883). React Native hides it for
|
||||
// inverted lists for the same reason (https://github.com/react/react-native/pull/58684). The app
|
||||
// draws its own header fade, so every edge is hidden; hiding only top and bottom leaves the side
|
||||
// effects, which also cover a transformed list on device.
|
||||
- (instancetype)initWithFrame:(CGRect)frame
|
||||
{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0
|
||||
if (@available(iOS 26.0, *)) {
|
||||
UIScrollView *scrollView = self.scrollView;
|
||||
scrollView.topEdgeEffect.hidden = YES;
|
||||
scrollView.bottomEdgeEffect.hidden = YES;
|
||||
scrollView.leftEdgeEffect.hidden = YES;
|
||||
scrollView.rightEdgeEffect.hidden = YES;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)mountingTransactionWillMount:(const MountingTransaction &)transaction
|
||||
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -75,6 +77,7 @@ export function useAgentCommandsQuery({
|
||||
}),
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const query = useFetchQuery({
|
||||
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
||||
queryFn: async () => {
|
||||
@@ -101,10 +104,20 @@ export function useAgentCommandsQuery({
|
||||
// isLoading is true when fetching and no data yet
|
||||
const isLoading = query.isPending || query.isLoading;
|
||||
|
||||
// Reset rather than refetch: a refetch keeps the error status while it runs, and the menu
|
||||
// shows loading for the retry.
|
||||
const retry = useCallback(() => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
||||
exact: true,
|
||||
});
|
||||
}, [agentId, draftConfig, queryClient, serverId]);
|
||||
|
||||
return {
|
||||
commands: query.data ?? [],
|
||||
isLoading,
|
||||
isError: query.isError && query.data === undefined,
|
||||
error: query.error,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol
|
||||
export interface AgentControlIconProps {
|
||||
size: number;
|
||||
color: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
|
||||
@@ -33,6 +35,7 @@ export function useAgentDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
useTranslation();
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[context.serverId];
|
||||
@@ -42,6 +45,7 @@ export function useAgentDescriptor(
|
||||
provider: agent?.provider ?? "codex",
|
||||
title: agent?.title ?? null,
|
||||
status: agent?.status ?? null,
|
||||
backgroundWork: agent?.backgroundWork,
|
||||
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
@@ -57,6 +61,7 @@ export function buildAgentDescriptor(input: {
|
||||
provider: Agent["provider"];
|
||||
title: string | null;
|
||||
status: Agent["status"] | null;
|
||||
backgroundWork?: Agent["backgroundWork"];
|
||||
pendingPermissionCount: number;
|
||||
requiresAttention: boolean;
|
||||
attentionReason: Agent["attentionReason"];
|
||||
@@ -65,20 +70,25 @@ export function buildAgentDescriptor(input: {
|
||||
}): PanelDescriptor {
|
||||
const provider = input.provider;
|
||||
const label = resolveAgentLabel(input.title);
|
||||
const statusBucket = input.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: input.isTurnActive ? "running" : input.status,
|
||||
backgroundWork: input.backgroundWork,
|
||||
pendingPermissionCount: input.pendingPermissionCount,
|
||||
requiresAttention: input.requiresAttention,
|
||||
attentionReason: input.attentionReason,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
subtitle:
|
||||
statusBucket === "running" && !input.isTurnActive && input.status !== "running"
|
||||
? i18n.t("message.backgroundWork.running")
|
||||
: `${formatProviderLabel(provider)} agent`,
|
||||
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon: input.icon,
|
||||
statusBucket: input.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: input.isTurnActive ? "running" : input.status,
|
||||
pendingPermissionCount: input.pendingPermissionCount,
|
||||
requiresAttention: input.requiresAttention,
|
||||
attentionReason: input.attentionReason,
|
||||
})
|
||||
: null,
|
||||
statusBucket,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -593,6 +593,92 @@ describe("timeline presentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("folds work before a plugin-rendered reply and leaves the reply visible", () => {
|
||||
const timestamp = new Date("2026-01-01T00:00:00.000Z");
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
|
||||
];
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: false,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
|
||||
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
|
||||
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
|
||||
});
|
||||
|
||||
it("keeps streaming replies native, then folds work without losing manual expansion", () => {
|
||||
const timestamp = new Date("2026-01-01T00:00:00.000Z");
|
||||
const work: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
];
|
||||
const reply: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "reply",
|
||||
text: "$$x^2$$",
|
||||
timestamp,
|
||||
};
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
|
||||
display: "collapseTurns" as const,
|
||||
};
|
||||
const present = createStreamPresentation();
|
||||
const live = present({ ...input, tail: work, head: [reply], isTurnActive: true });
|
||||
expect(rows(live).map((item) => item.id)).toEqual(["prompt", "tool-one", "reply"]);
|
||||
const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false };
|
||||
expect(present(completed).tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
const expandedTurnHostIds = new Set(["prompt"]);
|
||||
const opened = present({ ...completed, expandedTurnHostIds });
|
||||
expect(opened.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"tool-one",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
expect(opened.turns.nestedItemIds.has("probe-plugin/reply/0")).toBe(false);
|
||||
expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail);
|
||||
expect(present(completed).tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["full", "collapseToolCalls"] as const)(
|
||||
"leaves plugin replies and individual work visible in %s",
|
||||
(display) => {
|
||||
const timestamp = new Date("2026-01-01T00:00:00.000Z");
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
|
||||
];
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: installedTransform(installProbe("assistant_message")),
|
||||
tail,
|
||||
head: [],
|
||||
display,
|
||||
isTurnActive: false,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"tool-one",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
expect(result.turns.turnsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("opening a turn reveals individual calls rather than another tool group", () => {
|
||||
const present = createStreamPresentation();
|
||||
const timestamp = new Date();
|
||||
|
||||
@@ -675,7 +675,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
extraData={renderHistoryMountedRow}
|
||||
keyExtractor={keyExtractor}
|
||||
testID="agent-chat-scroll"
|
||||
// The Android scroll owner identifies the timeline to contain nested panel gestures.
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={historyStartContent}
|
||||
ListFooterComponent={liveAuxiliaryContent ?? undefined}
|
||||
|
||||
@@ -12,6 +12,21 @@ function reply(id: string): StreamItem {
|
||||
return { kind: "assistant_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function pluginReply(sourceId: string, part = "0"): StreamItem {
|
||||
return {
|
||||
kind: "plugin",
|
||||
id: `math/${sourceId}/${part}`,
|
||||
sourceId,
|
||||
sourceKind: "assistant_message",
|
||||
timestamp: at,
|
||||
pluginId: "math",
|
||||
pluginItemId: `${sourceId}/${part}`,
|
||||
itemKind: "formula",
|
||||
version: 1,
|
||||
data: { text: "$$x^2$$" },
|
||||
};
|
||||
}
|
||||
|
||||
function thought(id: string): StreamItem {
|
||||
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
|
||||
}
|
||||
@@ -49,6 +64,44 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("keeps every card of the final reply outside the folded turn", () => {
|
||||
const items = [
|
||||
user("prompt"),
|
||||
reply("acknowledgment"),
|
||||
shell("call"),
|
||||
pluginReply("final", "left"),
|
||||
pluginReply("final", "right"),
|
||||
];
|
||||
const result = collapse(items);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]);
|
||||
expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt");
|
||||
expect(result.hostIdByFoldedMessageId.has("final")).toBe(false);
|
||||
const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) });
|
||||
expect(expanded.tail).toEqual(items);
|
||||
expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]);
|
||||
});
|
||||
|
||||
it("folds intermediate plugin replies even without tool calls", () => {
|
||||
const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]);
|
||||
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
|
||||
});
|
||||
|
||||
it.each([undefined, "thought", "tool_call"] as const)(
|
||||
"does not treat a plugin row with source kind %s as a reply",
|
||||
(sourceKind) => {
|
||||
const card = { ...pluginReply("card"), sourceKind };
|
||||
const items = [user("prompt"), shell("call"), card];
|
||||
expect(collapse(items).tail).toEqual(items);
|
||||
expect(collapse(items).turnsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a plugin-rendered reply and its work unfolded while the turn is open", () => {
|
||||
const items = [user("prompt"), shell("call"), pluginReply("final")];
|
||||
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
|
||||
});
|
||||
|
||||
// A history row renders again only when its item changes, so a prompt that is already retained
|
||||
// when its turn closes needs its row revised to show the summary line.
|
||||
it("reports the hosts whose summary line appeared, changed or went away", () => {
|
||||
|
||||
@@ -100,9 +100,25 @@ function staysVisibleWhenCollapsed(item: StreamItem): boolean {
|
||||
return isTurnTrigger(item) || item.kind === "compaction";
|
||||
}
|
||||
|
||||
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
function isAssistantReply(item: StreamItem): boolean {
|
||||
return (
|
||||
item.kind === "assistant_message" ||
|
||||
(item.kind === "plugin" && item.sourceKind === "assistant_message")
|
||||
);
|
||||
}
|
||||
|
||||
function findLastReplyIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
if (tail[index]?.kind === "assistant_message") return index;
|
||||
const item = tail[index]!;
|
||||
if (!isAssistantReply(item)) continue;
|
||||
const messageId = getStreamItemMessageId(item);
|
||||
// A transformer can split one reply into several cards; keep the whole reply visible.
|
||||
while (index > start) {
|
||||
const previous = tail[index - 1]!;
|
||||
if (!isAssistantReply(previous) || getStreamItemMessageId(previous) !== messageId) break;
|
||||
index -= 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -125,7 +141,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const flushRun = (runEnd: number) => {
|
||||
const host = tail[runStart - 1];
|
||||
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
||||
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
||||
const replyIndex = findLastReplyIndex(tail, runStart, runEnd);
|
||||
if (
|
||||
!host ||
|
||||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
|
||||
@@ -137,7 +153,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
}
|
||||
const folded = tail.slice(runStart, replyIndex);
|
||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) {
|
||||
if (!hasWork(summary) && !folded.some(isAssistantReply)) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -218,8 +218,12 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
// The message above drops its bottom padding for the footer (compactBottom), so this margin
|
||||
// alone sets the gap. 20 puts the footer as far below the text as the text sits below a
|
||||
// tool row: 12 message padding + 4 stream gap + 5 badge padding/border, less the footer's
|
||||
// 1px taller row.
|
||||
turnFooterRow: {
|
||||
marginTop: theme.spacing[2] + 5,
|
||||
marginTop: theme.spacing[4] + theme.spacing[1],
|
||||
},
|
||||
turnFooterSlot: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -55,6 +55,7 @@ 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";
|
||||
@@ -557,6 +558,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<PluginCommandCenterActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<WorkspaceArchiveUndoShortcutHost />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { createHostBackgroundWorkModel } from "./host";
|
||||
|
||||
test("retries a failed observation and receives subsequent updates", async () => {
|
||||
interface Observer {
|
||||
snapshot(): void;
|
||||
update(message: SessionOutboundMessage): void;
|
||||
error(error: unknown): void;
|
||||
}
|
||||
const observers: Observer[] = [];
|
||||
let releases = 0;
|
||||
const item = {
|
||||
id: "shell",
|
||||
kind: "shell",
|
||||
description: "sleep",
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const client = {
|
||||
getLastServerInfoMessage: () => ({ features: { agentBackgroundWork: true } }),
|
||||
stopAgentBackgroundWork: async () => {},
|
||||
listAgentBackgroundWork: async () => [item],
|
||||
observeAgentBackgroundWork: () => ({
|
||||
subscribe: (observer: Observer) => {
|
||||
observers.push(observer);
|
||||
},
|
||||
release: async () => {
|
||||
releases++;
|
||||
},
|
||||
}),
|
||||
};
|
||||
const model = createHostBackgroundWorkModel("host", "agent", {
|
||||
getClient: () => client,
|
||||
subscribe: () => () => {},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
observers[0].snapshot();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observers[0].error(new Error("subscription failed"));
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
|
||||
model.retry();
|
||||
expect(observers).toHaveLength(2);
|
||||
expect(releases).toBe(1);
|
||||
observers[1].snapshot();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observers[0].error(new Error("late error"));
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observers[1].update({
|
||||
type: "agent.background_work.update",
|
||||
payload: { agentId: "agent", items: [] },
|
||||
});
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("starts observing after the same client reconnects", async () => {
|
||||
let connected = false;
|
||||
let notify = () => {};
|
||||
let snapshot = () => {};
|
||||
const item = {
|
||||
id: "shell",
|
||||
kind: "shell",
|
||||
description: "sleep",
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const client = {
|
||||
getLastServerInfoMessage: () =>
|
||||
connected ? { features: { agentBackgroundWork: true } } : null,
|
||||
stopAgentBackgroundWork: async () => {},
|
||||
listAgentBackgroundWork: async () => [item],
|
||||
observeAgentBackgroundWork: () => ({
|
||||
subscribe: (observer: { snapshot(): void }) => {
|
||||
snapshot = observer.snapshot;
|
||||
},
|
||||
release: async () => {},
|
||||
}),
|
||||
};
|
||||
const model = createHostBackgroundWorkModel("host", "agent", {
|
||||
getClient: () => client,
|
||||
subscribe: (listener) => {
|
||||
notify = listener;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
connected = true;
|
||||
notify();
|
||||
snapshot();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { createBackgroundWorkModel } from "./model";
|
||||
|
||||
interface BackgroundWorkClient {
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { agentBackgroundWork?: boolean; agentBackgroundWorkStop?: boolean } | null;
|
||||
} | null;
|
||||
stopAgentBackgroundWork(agentId: string, item: PaseoAgentBackgroundWorkItem): Promise<void>;
|
||||
listAgentBackgroundWork(agentId: string): Promise<PaseoAgentBackgroundWorkItem[]>;
|
||||
observeAgentBackgroundWork(): {
|
||||
subscribe(observer: {
|
||||
snapshot(): void;
|
||||
update(message: SessionOutboundMessage): void;
|
||||
error(error: unknown): void;
|
||||
}): unknown;
|
||||
release(): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
interface BackgroundWorkHost {
|
||||
getClient(): BackgroundWorkClient | null;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
function hostFor(serverId: string): BackgroundWorkHost {
|
||||
const host = getHostRuntimeStore();
|
||||
return {
|
||||
getClient: () => host.getSnapshot(serverId)?.client ?? null,
|
||||
subscribe: (listener) => host.subscribe(serverId, listener),
|
||||
};
|
||||
}
|
||||
|
||||
export function createHostBackgroundWorkModel(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
host: BackgroundWorkHost = hostFor(serverId),
|
||||
) {
|
||||
let client: BackgroundWorkClient | null = null;
|
||||
const controls = (items: PaseoAgentBackgroundWorkItem[]) => {
|
||||
// COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03.
|
||||
if (client?.getLastServerInfoMessage()?.features?.agentBackgroundWorkStop === true)
|
||||
return items;
|
||||
return items.map(({ canStop: _canStop, stopToken: _stopToken, ...item }) => item);
|
||||
};
|
||||
return createBackgroundWorkModel({
|
||||
stop: (item) => {
|
||||
if (!client) return Promise.reject(new Error("Host disconnected"));
|
||||
return client.stopAgentBackgroundWork(agentId, item);
|
||||
},
|
||||
list: () => {
|
||||
if (!client) return Promise.reject(new Error("Host disconnected"));
|
||||
return client.listAgentBackgroundWork(agentId).then(controls);
|
||||
},
|
||||
observe: ({ snapshot, update, disconnected, failed }) => {
|
||||
let release: (() => void) | undefined;
|
||||
let currentObservation:
|
||||
| ReturnType<BackgroundWorkClient["observeAgentBackgroundWork"]>
|
||||
| undefined;
|
||||
const sync = () => {
|
||||
const candidate = host.getClient();
|
||||
// The runtime keeps the client across reconnects, but clears server info until handshake.
|
||||
const next = candidate?.getLastServerInfoMessage() ? candidate : null;
|
||||
if (next === client) return;
|
||||
const previousRelease = release;
|
||||
client = next;
|
||||
release = undefined;
|
||||
disconnected();
|
||||
previousRelease?.();
|
||||
if (!client) return;
|
||||
// COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06.
|
||||
if (client.getLastServerInfoMessage()?.features?.agentBackgroundWork !== true) {
|
||||
failed();
|
||||
return;
|
||||
}
|
||||
const observedClient = client;
|
||||
const observation = client.observeAgentBackgroundWork();
|
||||
currentObservation = observation;
|
||||
observation.subscribe({
|
||||
snapshot: () => {
|
||||
if (client === observedClient && currentObservation === observation) snapshot();
|
||||
},
|
||||
update: (message) => {
|
||||
if (
|
||||
client === observedClient &&
|
||||
currentObservation === observation &&
|
||||
message.type === "agent.background_work.update" &&
|
||||
message.payload.agentId === agentId
|
||||
)
|
||||
update(controls(message.payload.items));
|
||||
},
|
||||
error: () => {
|
||||
if (client === observedClient && currentObservation === observation) failed();
|
||||
},
|
||||
});
|
||||
release = () => {
|
||||
if (currentObservation === observation) currentObservation = undefined;
|
||||
void observation
|
||||
.release()
|
||||
.catch((error: unknown) =>
|
||||
console.error("Background work subscription cleanup failed", error),
|
||||
);
|
||||
};
|
||||
};
|
||||
const unsubscribe = host.subscribe(sync);
|
||||
sync();
|
||||
return () => {
|
||||
unsubscribe();
|
||||
client = null;
|
||||
release?.();
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
|
||||
import { LiveElapsed } from "@/components/message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
|
||||
import { createHostBackgroundWorkModel } from "./host";
|
||||
import { backgroundWorkText } from "./presentation";
|
||||
export { createHostBackgroundWorkModel } from "./host";
|
||||
|
||||
function kindKey(kind: string): "shell" | "monitor" | "other" {
|
||||
if (kind === "shell" || kind === "monitor") return kind;
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function BackgroundWorkTrack({
|
||||
model,
|
||||
count,
|
||||
}: {
|
||||
model: ReturnType<typeof createHostBackgroundWorkModel>;
|
||||
count: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { items, status, stops } = useSyncExternalStore(
|
||||
model.subscribe,
|
||||
model.getSnapshot,
|
||||
model.getSnapshot,
|
||||
);
|
||||
const missing = Object.values(stops ?? {}).filter(
|
||||
(entry) => !items?.some((item) => item.stopToken === entry.item.stopToken),
|
||||
);
|
||||
const rows = [...(items ?? []), ...missing.map((entry) => entry.item)];
|
||||
if (count === 0 && rows.length === 0) return null;
|
||||
const visibleCount = items?.length || count;
|
||||
return (
|
||||
<ComposerTrackPill
|
||||
testID="background-work"
|
||||
panelTitle={t("message.backgroundWork.title")}
|
||||
segments={[
|
||||
{
|
||||
bucket: "running",
|
||||
text:
|
||||
visibleCount === 1
|
||||
? t("message.backgroundWork.pillOne")
|
||||
: t("message.backgroundWork.pillMany", { count: visibleCount }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{status === "loading" || status === "stale" ? (
|
||||
<ComposerTrackRow>
|
||||
<Text style={styles.facts}>{t("message.backgroundWork.waiting")}</Text>
|
||||
</ComposerTrackRow>
|
||||
) : null}
|
||||
{status === "error" ? (
|
||||
<>
|
||||
<ComposerTrackRow>
|
||||
<Text style={styles.facts}>{t("message.backgroundWork.error")}</Text>
|
||||
</ComposerTrackRow>
|
||||
<ComposerTrackRow
|
||||
onPress={model.retry}
|
||||
closeOnSelect={false}
|
||||
accessibilityLabel={t("message.backgroundWork.retry")}
|
||||
>
|
||||
<Text style={styles.title}>{t("message.backgroundWork.retry")}</Text>
|
||||
</ComposerTrackRow>
|
||||
</>
|
||||
) : null}
|
||||
{rows.map((item) => (
|
||||
<BackgroundWorkRow
|
||||
key={item.stopToken ?? item.id}
|
||||
item={item}
|
||||
model={model}
|
||||
live={Boolean(items?.some((entry) => entry.stopToken === item.stopToken))}
|
||||
ready={status === "ready"}
|
||||
stopStatus={stops?.[item.stopToken ?? ""]?.status}
|
||||
/>
|
||||
))}
|
||||
</ComposerTrackPill>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundWorkRow({
|
||||
item,
|
||||
model,
|
||||
live,
|
||||
ready,
|
||||
stopStatus,
|
||||
}: {
|
||||
item: PaseoAgentBackgroundWorkItem;
|
||||
model: ReturnType<typeof createHostBackgroundWorkModel>;
|
||||
live: boolean;
|
||||
ready: boolean;
|
||||
stopStatus?: "pending" | "error";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const kind = t(`message.backgroundWork.${kindKey(item.kind)}`);
|
||||
const stop = useCallback(() => {
|
||||
void model.stopItem(item);
|
||||
}, [model, item]);
|
||||
const dismiss = useCallback(
|
||||
() => model.dismissStop(item.stopToken ?? ""),
|
||||
[model, item.stopToken],
|
||||
);
|
||||
let detail = t("message.backgroundWork.notRunning");
|
||||
if (stopStatus === "pending") detail = t("message.backgroundWork.stopping");
|
||||
const { title, command } = backgroundWorkText(item, kind);
|
||||
const action = item.canStop && ready && live;
|
||||
const label =
|
||||
stopStatus === "pending"
|
||||
? t("message.backgroundWork.stopping")
|
||||
: t("message.backgroundWork.stop");
|
||||
return (
|
||||
<ComposerTrackRow>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{command ? <Text style={styles.facts}>{command}</Text> : null}
|
||||
<Text style={styles.facts}>
|
||||
{kind} ·{" "}
|
||||
{live ? (
|
||||
<LiveElapsed startedAt={new Date(item.startedAt)} active style={styles.facts} />
|
||||
) : (
|
||||
detail
|
||||
)}
|
||||
</Text>
|
||||
{stopStatus === "error" ? (
|
||||
<Text style={styles.facts}>{t("message.backgroundWork.stopError")}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{action ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
accessibilityLabel={`${t("message.backgroundWork.stop")} ${title}`}
|
||||
disabled={stopStatus === "pending"}
|
||||
onPress={stop}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
) : null}
|
||||
{!live && stopStatus === "error" ? (
|
||||
<Button variant="ghost" size="sm" onPress={dismiss}>
|
||||
{t("message.backgroundWork.dismiss")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ComposerTrackRow>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: { flexGrow: 1, flexShrink: 1, flexBasis: "auto", minWidth: 0 },
|
||||
title: { fontSize: theme.fontSize.base, color: theme.colors.foreground },
|
||||
facts: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
}));
|
||||
@@ -0,0 +1,233 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createBackgroundWorkModel } from "./model";
|
||||
|
||||
test("keeps a newer update when an earlier list resolves after it", async () => {
|
||||
let resolve!: (
|
||||
value: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
|
||||
) => void;
|
||||
let update!: (
|
||||
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
|
||||
) => void;
|
||||
let snapshot!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
update = callbacks.update;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
expect(model.getSnapshot().items).toBeNull();
|
||||
snapshot();
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
update([item]);
|
||||
resolve([]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().items).toEqual([item]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("retains the last inventory while reconnecting and replaces it after resync", async () => {
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
const reads: Array<(value: (typeof item)[]) => void> = [];
|
||||
let snapshot!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () => new Promise((resolve) => reads.push(resolve)),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
reads[0]([item]);
|
||||
await Promise.resolve();
|
||||
snapshot();
|
||||
expect(model.getSnapshot().items).toEqual([item]);
|
||||
reads[1]([]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().items).toEqual([]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("ignores a list response from a connection that was replaced", async () => {
|
||||
let snapshot!: () => void;
|
||||
let disconnected!: () => void;
|
||||
let resolve!: (
|
||||
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
|
||||
) => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
disconnected = callbacks.disconnected;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
disconnected();
|
||||
resolve([{ id: "old", kind: "shell", description: null, startedAt: "2026-01-01T00:00:00Z" }]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().items).toBeNull();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("retains stale items on failure and retries the inventory", async () => {
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
let snapshot!: () => void;
|
||||
let resolve!: (items: (typeof item)[]) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () =>
|
||||
new Promise((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
resolve([item]);
|
||||
await Promise.resolve();
|
||||
snapshot();
|
||||
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
|
||||
reject(new Error("offline"));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
|
||||
model.retry();
|
||||
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
|
||||
resolve([]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("reports an initial read failure without claiming an empty inventory", async () => {
|
||||
let snapshot!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("recreates a failed observation on retry and resumes live updates", async () => {
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
let observation!: Parameters<Parameters<typeof createBackgroundWorkModel>[0]["observe"]>[0];
|
||||
let starts = 0;
|
||||
let releases = 0;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => [item],
|
||||
observe: (callbacks) => {
|
||||
observation = callbacks;
|
||||
starts++;
|
||||
return () => {
|
||||
releases++;
|
||||
};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
observation.failed();
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
|
||||
model.retry();
|
||||
expect(starts).toBe(2);
|
||||
expect(releases).toBe(1);
|
||||
observation.snapshot();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observation.update([]);
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("keeps a stop pending across a roster removal and exposes a failed outcome", async () => {
|
||||
const item = {
|
||||
id: "1",
|
||||
kind: "shell",
|
||||
description: "sleep",
|
||||
startedAt: "2026-01-01",
|
||||
canStop: true,
|
||||
stopToken: "generation-1",
|
||||
};
|
||||
let update!: (items: (typeof item)[]) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => [item],
|
||||
stop: () =>
|
||||
new Promise((_, fail) => {
|
||||
reject = fail;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
update = callbacks.update;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
update([item]);
|
||||
const pending = model.stopItem(item);
|
||||
update([]);
|
||||
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "pending", item });
|
||||
reject(new Error("connection lost"));
|
||||
await pending;
|
||||
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "error", item });
|
||||
model.dismissStop("generation-1");
|
||||
expect(model.getSnapshot().stops).toEqual({});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("ignores a stop response from a disconnected generation", async () => {
|
||||
const first = {
|
||||
id: "1",
|
||||
kind: "shell",
|
||||
description: "old",
|
||||
startedAt: "2026-01-01",
|
||||
canStop: true,
|
||||
stopToken: "first",
|
||||
};
|
||||
const replacement = { ...first, description: "new", stopToken: "second" };
|
||||
let update!: (items: (typeof first)[]) => void;
|
||||
let disconnect!: () => void;
|
||||
let resolve!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => [],
|
||||
stop: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
update = callbacks.update;
|
||||
disconnect = callbacks.disconnected;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
update([first]);
|
||||
const pending = model.stopItem(first);
|
||||
disconnect();
|
||||
update([replacement]);
|
||||
resolve();
|
||||
await pending;
|
||||
expect(model.getSnapshot().items).toEqual([replacement]);
|
||||
expect(model.getSnapshot().stops?.first).toEqual({ status: "error", item: first });
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { PaseoAgentBackgroundWorkItem as AgentBackgroundWorkItem } from "@getpaseo/client";
|
||||
|
||||
interface Port {
|
||||
list(): Promise<AgentBackgroundWorkItem[]>;
|
||||
stop?(item: AgentBackgroundWorkItem): Promise<void>;
|
||||
observe(callbacks: {
|
||||
snapshot(): void;
|
||||
disconnected(): void;
|
||||
failed(): void;
|
||||
update(items: AgentBackgroundWorkItem[]): void;
|
||||
}): () => void;
|
||||
}
|
||||
|
||||
interface BackgroundWorkState {
|
||||
status: "loading" | "stale" | "ready" | "error";
|
||||
items: AgentBackgroundWorkItem[] | null;
|
||||
stops?: Record<string, { status: "pending" | "error"; item: AgentBackgroundWorkItem }>;
|
||||
}
|
||||
|
||||
export function createBackgroundWorkModel(port: Port) {
|
||||
let state: BackgroundWorkState = { status: "loading", items: null };
|
||||
let sequence = 0;
|
||||
let stopGeneration = 0;
|
||||
let stop: (() => void) | undefined;
|
||||
let observationFailed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const publish = (next: BackgroundWorkState) => {
|
||||
state =
|
||||
next.stops === undefined && state.stops === undefined
|
||||
? next
|
||||
: { ...next, stops: next.stops ?? state.stops };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
const refresh = async () => {
|
||||
const request = ++sequence;
|
||||
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
|
||||
try {
|
||||
const items = await port.list();
|
||||
if (request === sequence) publish({ status: "ready", items });
|
||||
} catch {
|
||||
if (request === sequence) publish({ status: "error", items: state.items });
|
||||
}
|
||||
};
|
||||
const start = () => {
|
||||
observationFailed = false;
|
||||
stop = port.observe({
|
||||
snapshot: () => {
|
||||
void refresh();
|
||||
},
|
||||
disconnected: () => {
|
||||
sequence++;
|
||||
stopGeneration++;
|
||||
const stops =
|
||||
state.stops &&
|
||||
Object.fromEntries(
|
||||
Object.entries(state.stops).map(([id, entry]) => [
|
||||
id,
|
||||
{ ...entry, status: "error" as const },
|
||||
]),
|
||||
);
|
||||
publish({ status: state.items === null ? "loading" : "stale", items: state.items, stops });
|
||||
},
|
||||
failed: () => {
|
||||
observationFailed = true;
|
||||
sequence++;
|
||||
publish({ status: "error", items: state.items });
|
||||
},
|
||||
update: (items) => {
|
||||
sequence++;
|
||||
publish({ status: "ready", items });
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
getSnapshot: () => state,
|
||||
dismissStop: (token: string) => {
|
||||
const stops = { ...state.stops };
|
||||
delete stops[token];
|
||||
publish({ ...state, stops });
|
||||
},
|
||||
stopItem: async (item: AgentBackgroundWorkItem) => {
|
||||
const token = item.stopToken;
|
||||
if (!port.stop || !token || state.stops?.[token]?.status === "pending") return;
|
||||
if (!state.items?.some((entry) => entry.stopToken === token)) return;
|
||||
const generation = stopGeneration;
|
||||
publish({ ...state, stops: { ...state.stops, [token]: { status: "pending", item } } });
|
||||
try {
|
||||
await port.stop(item);
|
||||
if (generation !== stopGeneration) return;
|
||||
const stops = { ...state.stops };
|
||||
delete stops[token];
|
||||
publish({ ...state, stops });
|
||||
} catch {
|
||||
if (generation === stopGeneration)
|
||||
publish({ ...state, stops: { ...state.stops, [token]: { status: "error", item } } });
|
||||
}
|
||||
},
|
||||
retry: () => {
|
||||
if (observationFailed) {
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
|
||||
start();
|
||||
} else {
|
||||
void refresh();
|
||||
}
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!stop) start();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) {
|
||||
sequence++;
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { backgroundWorkText } from "./presentation";
|
||||
|
||||
test("shows descriptions and commands once, with useful fallbacks", () => {
|
||||
expect(
|
||||
backgroundWorkText({ description: "Watch build", command: "tail -f build.log" }, "Monitor"),
|
||||
).toEqual({ title: "Watch build", command: "tail -f build.log" });
|
||||
expect(backgroundWorkText({ description: null, command: "sleep 10" }, "Shell")).toEqual({
|
||||
title: "sleep 10",
|
||||
command: null,
|
||||
});
|
||||
expect(backgroundWorkText({ description: "sleep 10", command: "sleep 10" }, "Shell")).toEqual({
|
||||
title: "sleep 10",
|
||||
command: null,
|
||||
});
|
||||
expect(backgroundWorkText({ description: null }, "Shell")).toEqual({
|
||||
title: "Shell",
|
||||
command: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export function backgroundWorkText(
|
||||
item: { description: string | null; command?: string },
|
||||
kind: string,
|
||||
): { title: string; command: string | null } {
|
||||
const title = item.description ?? item.command ?? kind;
|
||||
return { title, command: item.command && item.command !== title ? item.command : null };
|
||||
}
|
||||
@@ -41,7 +41,15 @@ export function BranchSwitcher({
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
||||
const {
|
||||
branchOptions,
|
||||
isLoadingBranches,
|
||||
isFetchingBranches,
|
||||
branchError,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
handleBranchSelect,
|
||||
} = useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId: serverId,
|
||||
normalizedWorkspaceId: workspaceId,
|
||||
@@ -73,6 +81,22 @@ export function BranchSwitcher({
|
||||
[branchLeadingSlot],
|
||||
);
|
||||
|
||||
let emptyText = t("branchSwitcher.empty");
|
||||
if (isLoadingBranches) emptyText = t("common.states.loading");
|
||||
else if (branchError) emptyText = "";
|
||||
|
||||
const errorFooter = useMemo(
|
||||
() =>
|
||||
branchError ? (
|
||||
<View style={styles.footer}>
|
||||
<Text accessibilityRole="alert" style={styles.error}>
|
||||
{isFetchingBranches ? t("common.states.loading") : t("branchSwitcher.failedToLoad")}
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined,
|
||||
[branchError, isFetchingBranches, t],
|
||||
);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return null;
|
||||
}
|
||||
@@ -85,6 +109,7 @@ export function BranchSwitcher({
|
||||
testID={testID}
|
||||
label={currentBranchName}
|
||||
open={isOpen}
|
||||
disabled={!isConnected}
|
||||
onPress={handleOpen}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("branchSwitcher.currentBranch", {
|
||||
@@ -103,7 +128,8 @@ export function BranchSwitcher({
|
||||
searchable
|
||||
placeholder={t("branchSwitcher.placeholder")}
|
||||
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
|
||||
emptyText={t("branchSwitcher.empty")}
|
||||
emptyText={emptyText}
|
||||
footer={errorFooter}
|
||||
title={t("branchSwitcher.title")}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
@@ -122,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
|
||||
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
|
||||
tooltipText: {
|
||||
color: theme.colors.popoverForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -13,7 +13,11 @@ 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_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
TOOLBAR_GLYPH_SIZES,
|
||||
toolbarContentSlot,
|
||||
type ToolbarGlyph,
|
||||
} from "@/components/ui/control-geometry";
|
||||
|
||||
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
@@ -61,7 +65,7 @@ interface CombinedModelSelectorProps {
|
||||
*/
|
||||
triggerFill?: boolean;
|
||||
toolbar?: {
|
||||
glyphSize: number;
|
||||
glyph: ToolbarGlyph;
|
||||
showCaret: boolean;
|
||||
};
|
||||
}
|
||||
@@ -253,11 +257,13 @@ export function CombinedModelSelector({
|
||||
chevron={toolbar?.showCaret === false ? null : undefined}
|
||||
>
|
||||
{selectedProvider.trim().length > 0 ? (
|
||||
<View style={toolbar?.glyphSize === 20 ? styles.toolbarGlyph20 : styles.toolbarGlyph16}>
|
||||
<View style={toolbar ? styles.toolbarGlyph : styles.glyph}>
|
||||
<ModelProviderGlyph
|
||||
provider={selectedProvider}
|
||||
serverId={serverId}
|
||||
size={toolbar?.glyphSize ?? ICON_SIZE.md}
|
||||
size={toolbar?.glyph.size ?? ICON_SIZE.md}
|
||||
strokeWidth={toolbar?.glyph.strokeWidth}
|
||||
tone={toolbar?.glyph.tone}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -293,31 +299,30 @@ 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],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
...toolbarContentSlot(theme.spacing[2]),
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
toolbarGlyph16: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
glyph: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
flexShrink: 0,
|
||||
},
|
||||
toolbarGlyph20: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
toolbarGlyph: {
|
||||
width: TOOLBAR_GLYPH_SIZES.label,
|
||||
height: TOOLBAR_GLYPH_SIZES.label,
|
||||
flexShrink: 0,
|
||||
},
|
||||
triggerPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: 0.5,
|
||||
@@ -329,9 +334,11 @@ 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,5 +1,11 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import {
|
||||
Pressable,
|
||||
type PressableStateCallbackType,
|
||||
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";
|
||||
@@ -9,7 +15,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useHostReportsUsage } from "@/usage";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { TOOLBAR_DISC_SIZE, type ToolbarRing } from "@/components/ui/control-geometry";
|
||||
import { ContextWindowDetails } from "./context-window-details";
|
||||
import { ContextWindowSheet } from "./context-window-sheet";
|
||||
import { formatContextPercentage } from "./context-window-meter.utils";
|
||||
@@ -21,8 +27,8 @@ interface ContextWindowMeterProps {
|
||||
usedTokens: number | null;
|
||||
totalCostUsd?: number | null;
|
||||
showPercentage?: boolean;
|
||||
/** Optional glyph envelope for icon-toolbar alignment. */
|
||||
glyphSize?: number;
|
||||
/** The ring's box and stroke when it sits in an icon toolbar. */
|
||||
toolbarRing?: ToolbarRing;
|
||||
}
|
||||
|
||||
const SVG_SIZE = 14;
|
||||
@@ -71,7 +77,7 @@ function getProgressColor(percentage: number, theme: Theme): string {
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
|
||||
function getMeterGeometry(showPercentage: boolean, ring?: ToolbarRing) {
|
||||
if (showPercentage) {
|
||||
return {
|
||||
svgSize: COMPACT_SVG_SIZE,
|
||||
@@ -80,8 +86,8 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
|
||||
containerStyle: styles.containerWithLabel,
|
||||
};
|
||||
}
|
||||
const resolvedSize = glyphSize ?? SVG_SIZE;
|
||||
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
|
||||
const resolvedSize = ring?.size ?? SVG_SIZE;
|
||||
const resolvedStrokeWidth = ring?.strokeWidth ?? STROKE_WIDTH;
|
||||
return {
|
||||
svgSize: resolvedSize,
|
||||
radius: (resolvedSize - resolvedStrokeWidth) / 2,
|
||||
@@ -151,7 +157,7 @@ export function ContextWindowMeter({
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
showPercentage = false,
|
||||
glyphSize,
|
||||
toolbarRing,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
@@ -165,7 +171,7 @@ export function ContextWindowMeter({
|
||||
const closeSheet = useCallback(() => setIsSheetOpen(false), []);
|
||||
const percentage =
|
||||
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
||||
const geometry = getMeterGeometry(showPercentage, glyphSize);
|
||||
const geometry = getMeterGeometry(showPercentage, toolbarRing);
|
||||
|
||||
const context = useMemo(
|
||||
() =>
|
||||
@@ -184,6 +190,10 @@ 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}
|
||||
@@ -207,7 +217,7 @@ export function ContextWindowMeter({
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
style={pressableStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
@@ -241,7 +251,7 @@ export function ContextWindowMeter({
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
style={pressableStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
@@ -307,20 +317,23 @@ export function ContextWindowMeter({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
containerWithLabel: {
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
pressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
percentageLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -216,17 +216,19 @@ 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} color={color} />;
|
||||
return <Icon size={size} strokeWidth={strokeWidth} color={color} />;
|
||||
}
|
||||
|
||||
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
|
||||
|
||||
@@ -20,6 +20,8 @@ import { resolveProviderIconName } from "@/components/provider-icon-name";
|
||||
export interface ProviderIconProps {
|
||||
size: number;
|
||||
color: string;
|
||||
/** Honoured by outline glyphs such as the fallback; brand marks are filled and ignore it. */
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
|
||||
|
||||
@@ -649,6 +649,7 @@ function WorkspaceRowRightGroup({
|
||||
const {
|
||||
trailingPresentation,
|
||||
showKebab: showKebabInSlot,
|
||||
showArchiveButton,
|
||||
showScrim,
|
||||
renderSlot,
|
||||
reserveSlotWidth,
|
||||
@@ -675,6 +676,8 @@ function WorkspaceRowRightGroup({
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
scrimBackdrop={showScrim ? backdrop : undefined}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
|
||||
>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
|
||||
@@ -872,6 +872,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
const {
|
||||
trailingPresentation,
|
||||
showKebab: showKebabInSlot,
|
||||
showArchiveButton,
|
||||
showScrim,
|
||||
renderSlot,
|
||||
reserveSlotWidth,
|
||||
@@ -951,6 +952,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
trailing={trailing}
|
||||
trailingPresentation={trailingPresentation}
|
||||
showKebab={showKebabInSlot}
|
||||
showArchiveButton={showArchiveButton}
|
||||
showScrim={showScrim}
|
||||
reserveSlotWidth={reserveSlotWidth}
|
||||
isPinned={isPinned}
|
||||
@@ -982,6 +984,7 @@ function StatusWorkspaceActionSlot({
|
||||
trailing,
|
||||
trailingPresentation,
|
||||
showKebab,
|
||||
showArchiveButton,
|
||||
showScrim,
|
||||
reserveSlotWidth,
|
||||
isPinned,
|
||||
@@ -1002,6 +1005,7 @@ function StatusWorkspaceActionSlot({
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
trailingPresentation: SidebarWorkspaceTrailingPresentation;
|
||||
showKebab: boolean;
|
||||
showArchiveButton: boolean;
|
||||
showScrim: boolean;
|
||||
reserveSlotWidth: boolean;
|
||||
isPinned?: boolean;
|
||||
@@ -1026,6 +1030,8 @@ 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,6 +13,7 @@ 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";
|
||||
@@ -72,6 +73,9 @@ 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);
|
||||
|
||||
@@ -79,6 +83,33 @@ 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;
|
||||
@@ -437,6 +468,12 @@ 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.
|
||||
@@ -455,4 +492,13 @@ 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,7 +29,11 @@ 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 { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import { SCRIM_WIDTH, TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import {
|
||||
ARCHIVE_BUTTON_SIZE,
|
||||
SidebarWorkspaceArchiveButton,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
|
||||
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
@@ -156,7 +160,11 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
)}
|
||||
<View style={styles.workspaceContentColumn}>
|
||||
<View style={styles.workspaceTitleRow}>
|
||||
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
||||
<Text
|
||||
style={workspaceBranchTextStyle}
|
||||
numberOfLines={1}
|
||||
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
|
||||
>
|
||||
{workspaceLabel}
|
||||
</Text>
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
@@ -330,6 +338,8 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -370,6 +380,7 @@ export function resolveTrailingActionVisibility({
|
||||
}): {
|
||||
trailingPresentation: SidebarWorkspaceTrailingPresentation;
|
||||
showKebab: boolean;
|
||||
showArchiveButton: boolean;
|
||||
showScrim: boolean;
|
||||
renderSlot: boolean;
|
||||
reserveSlotWidth: boolean;
|
||||
@@ -384,6 +395,9 @@ 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.
|
||||
@@ -432,23 +446,46 @@ 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} testID="sidebar-workspace-trailing-scrim" />
|
||||
<TrailingActionScrim
|
||||
backdrop={scrimBackdrop}
|
||||
width={onArchiveButtonPress ? SCRIM_WIDTH + ARCHIVE_BUTTON_SCRIM_WIDTH : SCRIM_WIDTH}
|
||||
testID="sidebar-workspace-trailing-scrim"
|
||||
/>
|
||||
) : null}
|
||||
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
|
||||
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>
|
||||
{onArchiveButtonPress ? (
|
||||
<SidebarWorkspaceArchiveButton
|
||||
workspaceKey={workspaceKey}
|
||||
onArchive={onArchiveButtonPress}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -404,6 +404,7 @@ function WorkspaceRowTrailingActions({
|
||||
const {
|
||||
trailingPresentation,
|
||||
showKebab: showKebabInSlot,
|
||||
showArchiveButton,
|
||||
showScrim,
|
||||
renderSlot,
|
||||
reserveSlotWidth,
|
||||
@@ -430,6 +431,8 @@ function WorkspaceRowTrailingActions({
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
scrimBackdrop={showScrim ? backdrop : undefined}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
|
||||
>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
|
||||
@@ -35,6 +35,8 @@ 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 {
|
||||
@@ -45,6 +47,7 @@ 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. */
|
||||
@@ -78,40 +81,56 @@ export function useToastHost(): {
|
||||
dismiss: () => void;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const [toast, setToastState] = useState<ToastState | null>(null);
|
||||
const toastRef = useRef<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
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 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,
|
||||
onDismiss: options?.onDismiss,
|
||||
});
|
||||
return () => {
|
||||
if (toastRef.current?.id === id) setToast(null);
|
||||
};
|
||||
},
|
||||
[setToast],
|
||||
);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
show,
|
||||
@@ -127,7 +146,7 @@ export function useToastHost(): {
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setToast(null);
|
||||
}, []);
|
||||
}, [setToast]);
|
||||
|
||||
return { api, toast, dismiss };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ import { Portal } from "@gorhom/portal";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import {
|
||||
Autocomplete,
|
||||
type AutocompleteOption,
|
||||
type AutocompleteStatus,
|
||||
} from "@/components/ui/autocomplete";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
@@ -37,10 +41,7 @@ interface AutocompletePopoverProps {
|
||||
options: readonly AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
isLoading?: boolean;
|
||||
errorMessage?: string;
|
||||
loadingText?: string;
|
||||
emptyText?: string;
|
||||
status?: AutocompleteStatus | null;
|
||||
}
|
||||
|
||||
export function AutocompletePopover({
|
||||
@@ -50,10 +51,7 @@ export function AutocompletePopover({
|
||||
options,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
loadingText,
|
||||
emptyText,
|
||||
status,
|
||||
}: AutocompletePopoverProps): ReactElement | null {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
@@ -154,10 +152,7 @@ export function AutocompletePopover({
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
isLoading={isLoading}
|
||||
errorMessage={errorMessage}
|
||||
loadingText={loadingText}
|
||||
emptyText={emptyText}
|
||||
status={status}
|
||||
/>
|
||||
</Animated.View>
|
||||
</KeyboardTranslateView>
|
||||
|
||||
@@ -9,32 +9,79 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { File, Folder } from "lucide-react-native";
|
||||
import {
|
||||
Archive,
|
||||
CircleAlert,
|
||||
File,
|
||||
Folder,
|
||||
Puzzle,
|
||||
RotateCw,
|
||||
Sparkles,
|
||||
SquarePen,
|
||||
SquareSlash,
|
||||
} from "lucide-react-native";
|
||||
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { Button } from "./button";
|
||||
import { HighlightedText } from "./highlighted-text";
|
||||
import { mutedIconColorMapping } from "./icon-color";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
export type AutocompleteOptionIcon =
|
||||
| "command"
|
||||
| "skill"
|
||||
| "plugin"
|
||||
| "archive"
|
||||
| "new-draft"
|
||||
| "file"
|
||||
| "directory";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
icon: AutocompleteOptionIcon;
|
||||
/** Spans of `label` the query matched. */
|
||||
labelMatches?: readonly MatchRange[];
|
||||
argumentHint?: string;
|
||||
description?: string;
|
||||
kind?: "command" | "file" | "directory";
|
||||
/** Where the option comes from, when that is not the provider itself: `Skill`, `Paseo`, a plugin. */
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
/** What the list shows in place of options. */
|
||||
export type AutocompleteStatus =
|
||||
| { kind: "loading"; message: string }
|
||||
| { kind: "empty"; message: string }
|
||||
| { kind: "error"; title: string; reason?: string; onRetry?: () => void };
|
||||
|
||||
interface AutocompleteProps {
|
||||
options: readonly AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
isLoading?: boolean;
|
||||
errorMessage?: string;
|
||||
loadingText?: string;
|
||||
emptyText?: string;
|
||||
status?: AutocompleteStatus | null;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
const BOLT_GLYPH_PATTERN = /\u26A1|\uFE0F/gu;
|
||||
const ROW_ICONS = {
|
||||
command: withUnistyles(SquareSlash, mutedIconColorMapping),
|
||||
skill: withUnistyles(Sparkles, mutedIconColorMapping),
|
||||
plugin: withUnistyles(Puzzle, mutedIconColorMapping),
|
||||
archive: withUnistyles(Archive, mutedIconColorMapping),
|
||||
"new-draft": withUnistyles(SquarePen, mutedIconColorMapping),
|
||||
file: withUnistyles(File, mutedIconColorMapping),
|
||||
directory: withUnistyles(Folder, mutedIconColorMapping),
|
||||
} satisfies Record<AutocompleteOptionIcon, unknown>;
|
||||
const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping);
|
||||
|
||||
const ROW_ICON_SIZE = 14;
|
||||
|
||||
const BOLT_GLYPH_PATTERN = /⚡|️/gu;
|
||||
|
||||
function removeBoltGlyphs(value?: string): string | undefined {
|
||||
if (!value) {
|
||||
@@ -48,7 +95,7 @@ interface AutocompleteRowProps {
|
||||
index: number;
|
||||
option: AutocompleteOption;
|
||||
isSelected: boolean;
|
||||
mutedColor: string;
|
||||
showArgumentHint: boolean;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
|
||||
}
|
||||
@@ -57,13 +104,15 @@ function AutocompleteRow({
|
||||
index,
|
||||
option,
|
||||
isSelected,
|
||||
mutedColor,
|
||||
showArgumentHint,
|
||||
onSelect,
|
||||
onRowLayout,
|
||||
}: AutocompleteRowProps) {
|
||||
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const optionDescription = removeBoltGlyphs(option.description);
|
||||
const isFileOrDir = option.kind === "directory" || option.kind === "file";
|
||||
const label = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const description = removeBoltGlyphs(option.description);
|
||||
const argumentHint = showArgumentHint ? removeBoltGlyphs(option.argumentHint) : undefined;
|
||||
const Icon = ROW_ICONS[option.icon];
|
||||
const labelMatches = label === option.label ? option.labelMatches : undefined;
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => onRowLayout(index, event),
|
||||
@@ -79,41 +128,77 @@ function AutocompleteRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable onLayout={handleLayout} onPress={handlePress} style={pressableStyle}>
|
||||
{isFileOrDir ? (
|
||||
<>
|
||||
<View style={styles.itemLeading}>
|
||||
{option.kind === "directory" ? (
|
||||
<Folder size={14} color={mutedColor} />
|
||||
) : (
|
||||
<File size={14} color={mutedColor} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.itemMain}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||
{removeBoltGlyphs(option.detail) ? (
|
||||
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{optionDescription ? (
|
||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||
{optionDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.itemMainRow}>
|
||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||
{optionDescription ? (
|
||||
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
|
||||
{optionDescription}
|
||||
<Pressable
|
||||
onLayout={handleLayout}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
// Name and description only; the hint and tag repeat what the name implies.
|
||||
accessibilityLabel={description ? `${label}, ${description}` : label}
|
||||
>
|
||||
<View style={styles.itemIcon}>
|
||||
<Icon size={ROW_ICON_SIZE} />
|
||||
</View>
|
||||
<HighlightedText
|
||||
text={label}
|
||||
ranges={labelMatches}
|
||||
style={styles.itemLabel}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
{argumentHint ? (
|
||||
<Text style={styles.itemArgumentHint} numberOfLines={1}>
|
||||
{argumentHint}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||
{description ?? ""}
|
||||
</Text>
|
||||
{option.tag ? <StatusBadge label={option.tag} size="xs" /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (status.kind === "error") {
|
||||
return (
|
||||
<View style={styles.statusRow} testID="composer-autocomplete-error">
|
||||
<View style={styles.itemIcon}>
|
||||
<ErrorIcon size={ROW_ICON_SIZE} />
|
||||
</View>
|
||||
<View style={styles.statusText}>
|
||||
<Text style={styles.statusTitle} numberOfLines={2}>
|
||||
{status.title}
|
||||
</Text>
|
||||
{status.reason ? (
|
||||
<Text style={styles.statusReason} numberOfLines={2}>
|
||||
{status.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{status.onRetry ? (
|
||||
<Button variant="secondary" size="sm" leftIcon={RotateCw} onPress={status.onRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.statusRow}
|
||||
testID={status.kind === "loading" ? "composer-autocomplete-loading" : undefined}
|
||||
>
|
||||
{status.kind === "loading" ? (
|
||||
<View style={styles.itemIcon}>
|
||||
<ThemedLoadingSpinner size={ROW_ICON_SIZE} />
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.statusMessage} numberOfLines={2}>
|
||||
{status.message}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,16 +206,11 @@ export function Autocomplete({
|
||||
options,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
errorMessage,
|
||||
loadingText,
|
||||
emptyText,
|
||||
status,
|
||||
maxHeight = 220,
|
||||
}: AutocompleteProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedLoadingText = loadingText ?? t("common.states.loading");
|
||||
const resolvedEmptyText = emptyText ?? t("common.empty.noResults");
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
||||
const viewportHeightRef = useRef(0);
|
||||
@@ -210,117 +290,51 @@ export function Autocomplete({
|
||||
[ensureActiveItemVisible],
|
||||
);
|
||||
|
||||
const selectedOption = options[selectedIndex];
|
||||
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>{resolvedLoadingText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const noResultsMessage = t("common.empty.noResults");
|
||||
const shownStatus = useMemo<AutocompleteStatus | null>(
|
||||
() => status ?? (options.length === 0 ? { kind: "empty", message: noResultsMessage } : null),
|
||||
[noResultsMessage, options.length, status],
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
if (shownStatus) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>{resolvedEmptyText}</Text>
|
||||
</View>
|
||||
<AutocompleteStatusRow status={shownStatus} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.outerWrapper}>
|
||||
{selectedOption?.kind === "command" && selectedOption.description ? (
|
||||
<View style={styles.detailCard}>
|
||||
<Text style={styles.detailLabel} numberOfLines={1}>
|
||||
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
|
||||
</Text>
|
||||
<Text style={styles.detailDescription} numberOfLines={2} ellipsizeMode="tail">
|
||||
{removeBoltGlyphs(selectedOption.description)}
|
||||
</Text>
|
||||
{selectedOption.detail ? (
|
||||
<Text style={styles.detailHint} numberOfLines={1}>
|
||||
{removeBoltGlyphs(selectedOption.detail)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={containerStyle}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<AutocompleteRow
|
||||
key={option.id}
|
||||
index={index}
|
||||
option={option}
|
||||
isSelected={index === selectedIndex}
|
||||
mutedColor={theme.colors.foregroundMuted}
|
||||
onSelect={onSelect}
|
||||
onRowLayout={handleRowLayout}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<View style={containerStyle}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<AutocompleteRow
|
||||
key={option.id}
|
||||
index={index}
|
||||
option={option}
|
||||
isSelected={index === selectedIndex}
|
||||
showArgumentHint={!isCompact}
|
||||
onSelect={onSelect}
|
||||
onRowLayout={handleRowLayout}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
outerWrapper: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailCard: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
...theme.shadow.md,
|
||||
},
|
||||
detailLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
detailDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
detailHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
container: {
|
||||
flexShrink: 1,
|
||||
// Keep a complete selectable row (36), list padding (8), and borders (2).
|
||||
@@ -342,60 +356,63 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
itemLeading: {
|
||||
width: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
itemActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
itemMainRow: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
itemIcon: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
itemHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
justifyContent: "center",
|
||||
},
|
||||
itemLabel: {
|
||||
flexShrink: 0,
|
||||
maxWidth: "60%",
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
itemDetail: {
|
||||
itemArgumentHint: {
|
||||
flexShrink: 0,
|
||||
maxWidth: "30%",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
itemDescription: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
itemDescriptionInline: {
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 44,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
statusText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
statusTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
statusReason: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
statusMessage: {
|
||||
flex: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
emptyItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
})) as unknown as Record<string, object>;
|
||||
|
||||
@@ -12,10 +12,19 @@ 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";
|
||||
@@ -109,6 +118,62 @@ 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, type Theme } from "@/styles/theme";
|
||||
import { ICON_SIZE, ICON_STROKE_WIDTH, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
bySizing,
|
||||
controlSizingForLayout,
|
||||
@@ -87,20 +87,63 @@ export const ICON_BUTTON_SIZES = {
|
||||
} satisfies Record<"large" | "small", SizingValue>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
|
||||
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
|
||||
|
||||
/**
|
||||
* The painted disc of a filled toolbar control, such as the composer's send and stop. The
|
||||
* control keeps the full toolbar hit frame and paints this smaller disc centered inside it, so
|
||||
* touch-sized it does not outweigh its unpainted neighbours. Pointer-sized it fills the frame.
|
||||
* 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.
|
||||
@@ -112,6 +155,81 @@ 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)];
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { Portal, PortalProvider } from "@gorhom/portal";
|
||||
import { FloatingPanelPortalHost, measureFloatingPanelPortalHost } from "./floating-panel-portal";
|
||||
|
||||
describe("Floating panel host lifetime", () => {
|
||||
test("removing the newest host preserves the surviving host measurement", async () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "position:relative;width:800px;height:600px";
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
function Content({ overlap }: { overlap: boolean }) {
|
||||
return (
|
||||
<PortalProvider shouldAddRootHost={false}>
|
||||
<FloatingPanelPortalHost name="workspace" />
|
||||
{overlap && <FloatingPanelPortalHost name="workspace" />}
|
||||
<Portal hostName="workspace" name="picker">
|
||||
<div data-testid="picker">Commands</div>
|
||||
</Portal>
|
||||
</PortalProvider>
|
||||
);
|
||||
}
|
||||
try {
|
||||
await act(async () => root.render(<Content overlap={true} />));
|
||||
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
|
||||
await act(async () => root.render(<Content overlap={false} />));
|
||||
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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 }));
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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,9 +6,18 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export const SCRIM_WIDTH = 48;
|
||||
const SCRIM_SOLID_OFFSET = "55%";
|
||||
// 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;
|
||||
|
||||
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
|
||||
function TrailingActionScrimSvg({
|
||||
gradientId,
|
||||
color,
|
||||
solidOffset,
|
||||
}: {
|
||||
gradientId: string;
|
||||
color: string;
|
||||
solidOffset: string;
|
||||
}) {
|
||||
return (
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
@@ -16,7 +25,7 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col
|
||||
{/* 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={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
|
||||
<Stop offset={solidOffset} stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
@@ -39,17 +48,22 @@ 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} pointerEvents="none" testID={testID}>
|
||||
<View style={styles.scrim(width)} pointerEvents="none" testID={testID}>
|
||||
<ThemedTrailingActionScrimSvg
|
||||
gradientId={gradientId}
|
||||
solidOffset={solidOffset}
|
||||
uniProps={backdropColorMappings[backdrop]}
|
||||
/>
|
||||
</View>
|
||||
@@ -57,11 +71,11 @@ export function TrailingActionScrim({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrim: {
|
||||
scrim: (width: number) => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: SCRIM_WIDTH,
|
||||
},
|
||||
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 { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot } 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,10 +42,13 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { glyphSize } = useComposerControlLayout();
|
||||
const { glyphs } = useComposerControlLayout();
|
||||
const glyphRole = showToolbarLabel ? "label" : "icon";
|
||||
const glyph = glyphs[glyphRole];
|
||||
const isSheet = surface === "sheet";
|
||||
const resolvedGlyphSize = isSheet ? 16 : glyphSize;
|
||||
const resolvedIconColor = iconColor ?? styles.iconColor.color;
|
||||
const toolbarIconColor =
|
||||
glyph.tone === "foreground" ? styles.iconColorForeground.color : styles.iconColor.color;
|
||||
const resolvedIconColor = iconColor ?? (isSheet ? styles.iconColor.color : toolbarIconColor);
|
||||
const showValue = isSheet || showToolbarLabel;
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType) => [
|
||||
@@ -73,11 +76,11 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
||||
>
|
||||
{isSheet ? (
|
||||
<View style={styles.sheetGlyph}>
|
||||
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
|
||||
<Icon size={16} color={resolvedIconColor} />
|
||||
</View>
|
||||
) : (
|
||||
<ComposerToolbarGlyph size={resolvedGlyphSize}>
|
||||
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
|
||||
<ComposerToolbarGlyph role={glyphRole}>
|
||||
<Icon size={glyph.size} strokeWidth={glyph.strokeWidth} color={resolvedIconColor} />
|
||||
</ComposerToolbarGlyph>
|
||||
)}
|
||||
{isSheet ? (
|
||||
@@ -97,18 +100,17 @@ 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],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
...toolbarContentSlot(theme.spacing[2]),
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
toolbarIconOnly: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
...toolbarSlot(TOUCH_ICON_SIZE.md),
|
||||
flexShrink: 0,
|
||||
paddingHorizontal: 0,
|
||||
justifyContent: "center",
|
||||
@@ -160,7 +162,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
pressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
@@ -168,4 +170,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
iconColorForeground: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { TOOLBAR_GLYPH_SIZES, type ToolbarGlyphs } from "@/components/ui/control-geometry";
|
||||
|
||||
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
|
||||
/**
|
||||
* 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;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={size >= 20 ? styles.touch : styles.pointer}
|
||||
style={role === "label" ? styles.labelSlot : styles.iconSlot}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
pointerEvents="none"
|
||||
@@ -14,19 +26,9 @@ export function ComposerToolbarGlyph({ children, size }: { children: ReactNode;
|
||||
);
|
||||
}
|
||||
|
||||
const slot = { flexShrink: 0, alignItems: "center", justifyContent: "center" } as const;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pointer: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
touch: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.icon, height: TOOLBAR_GLYPH_SIZES.icon },
|
||||
labelSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.label, height: TOOLBAR_GLYPH_SIZES.label },
|
||||
});
|
||||
|
||||
@@ -61,7 +61,16 @@ import {
|
||||
resolveAgentModelSelection,
|
||||
} from "@/composer/agent-controls/utils";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { controlSizingForLayout, TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
controlSizingForLayout,
|
||||
TOOLBAR_CONTROL_GAP,
|
||||
bySizing,
|
||||
toolbarGlyphColor,
|
||||
type ToolbarGlyphs,
|
||||
TOUCH_ICON_SIZE,
|
||||
toolbarContentSlot,
|
||||
toolbarSlot,
|
||||
} 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";
|
||||
@@ -79,7 +88,10 @@ import {
|
||||
type ComposerControlDensity,
|
||||
type ComposerControlPresentation,
|
||||
} from "@/composer/agent-controls/layout";
|
||||
import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context";
|
||||
import {
|
||||
ComposerControlLayoutProvider,
|
||||
useComposerControlLayout,
|
||||
} 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";
|
||||
@@ -219,10 +231,10 @@ function getFeatureIconColor(
|
||||
green: { 400: string };
|
||||
yellow: { 400: string };
|
||||
},
|
||||
foregroundMuted: string,
|
||||
restColor: string,
|
||||
): string {
|
||||
if (!isFeatureActive(feature)) {
|
||||
return foregroundMuted;
|
||||
return restColor;
|
||||
}
|
||||
|
||||
switch (getFeatureHighlightColor(feature.id)) {
|
||||
@@ -233,7 +245,7 @@ function getFeatureIconColor(
|
||||
case "yellow":
|
||||
return palette.yellow[400];
|
||||
default:
|
||||
return foregroundMuted;
|
||||
return restColor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,7 +597,7 @@ function ControlledAgentControls({
|
||||
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
|
||||
const layoutContextValue = useMemo(
|
||||
() => ({
|
||||
glyphSize: toolbarGeometry.glyphSize,
|
||||
glyphs: toolbarGeometry.glyphs,
|
||||
presentation,
|
||||
}),
|
||||
[presentation, toolbarGeometry],
|
||||
@@ -799,7 +811,7 @@ function ControlledAgentControls({
|
||||
renderThinkingOption={renderThinkingOption}
|
||||
modeControl={modeControl}
|
||||
presentation={presentation}
|
||||
glyphSize={layoutContextValue.glyphSize}
|
||||
glyphs={layoutContextValue.glyphs}
|
||||
activeSheet={activeSheet}
|
||||
handleOpenSheet={handleOpenSheet}
|
||||
handleCloseSheet={handleCloseSheet}
|
||||
@@ -838,7 +850,7 @@ function ControlledAgentControls({
|
||||
handleOpenChange={handleSheetOpenChange}
|
||||
renderThinkingOption={renderThinkingOption}
|
||||
modeControl={modeControl}
|
||||
glyphSize={layoutContextValue.glyphSize}
|
||||
glyphs={layoutContextValue.glyphs}
|
||||
showLabels={presentation.showModelLabel}
|
||||
modelSelectorServerId={modelSelectorServerId}
|
||||
canSwitchProvider={Boolean(onSelectProviderAndModel)}
|
||||
@@ -900,7 +912,7 @@ interface DesktopAgentControlsContentProps {
|
||||
}) => ReactElement;
|
||||
modeControl?: AgentModeControlValue | null;
|
||||
presentation: ComposerControlPresentation;
|
||||
glyphSize: number;
|
||||
glyphs: ToolbarGlyphs;
|
||||
activeSheet: ActiveSheet;
|
||||
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
|
||||
handleCloseSheet: () => void;
|
||||
@@ -957,15 +969,15 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
renderThinkingOption,
|
||||
modeControl,
|
||||
presentation,
|
||||
glyphSize,
|
||||
glyphs,
|
||||
activeSheet,
|
||||
handleOpenSheet,
|
||||
handleCloseSheet,
|
||||
modelSelectorServerId,
|
||||
} = props;
|
||||
const modelToolbar = useMemo(
|
||||
() => ({ glyphSize, showCaret: presentation.showCarets }),
|
||||
[glyphSize, presentation.showCarets],
|
||||
() => ({ glyph: glyphs.label, showCaret: presentation.showCarets }),
|
||||
[glyphs, presentation.showCarets],
|
||||
);
|
||||
const featuresSheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: t("agentControls.features.title") }),
|
||||
@@ -1086,8 +1098,12 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
accessibilityLabel={t("agentControls.features.open")}
|
||||
testID="agent-controls-features"
|
||||
>
|
||||
<ComposerToolbarGlyph size={glyphSize}>
|
||||
<Settings2 size={glyphSize} color={theme.colors.foregroundMuted} />
|
||||
<ComposerToolbarGlyph role="icon">
|
||||
<Settings2
|
||||
size={glyphs.icon.size}
|
||||
strokeWidth={glyphs.icon.strokeWidth}
|
||||
color={toolbarGlyphColor(theme, glyphs.icon.tone)}
|
||||
/>
|
||||
</ComposerToolbarGlyph>
|
||||
</Pressable>
|
||||
<AdaptiveModalSheet
|
||||
@@ -1162,7 +1178,7 @@ interface SheetAgentControlsContentProps {
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
modeControl?: AgentModeControlValue | null;
|
||||
glyphSize: number;
|
||||
glyphs: ToolbarGlyphs;
|
||||
showLabels: boolean;
|
||||
modelSelectorServerId: string | null;
|
||||
canSwitchProvider: boolean;
|
||||
@@ -1202,7 +1218,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
handleOpenChange,
|
||||
renderThinkingOption,
|
||||
modeControl,
|
||||
glyphSize,
|
||||
glyphs,
|
||||
showLabels,
|
||||
modelSelectorServerId,
|
||||
canSwitchProvider,
|
||||
@@ -1291,7 +1307,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
onRetryProvider={onRetryModelProvider}
|
||||
isRetryingProvider={isRetryingModelProvider}
|
||||
serverId={modelSelectorServerId}
|
||||
glyphSize={glyphSize}
|
||||
glyphs={glyphs}
|
||||
showLabels={showLabels}
|
||||
canSwitchProvider={canSwitchProvider}
|
||||
>
|
||||
@@ -1316,6 +1332,8 @@ 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);
|
||||
|
||||
@@ -1356,11 +1374,7 @@ function DesktopFeatureItem({
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<AgentControlTrigger
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
iconColor={getFeatureIconColor(feature, theme.colors.palette, restColor)}
|
||||
surface="toolbar"
|
||||
label={feature.label}
|
||||
showToolbarLabel={false}
|
||||
@@ -1391,11 +1405,7 @@ function DesktopFeatureItem({
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
iconColor={getFeatureIconColor(feature, theme.colors.palette, restColor)}
|
||||
surface="toolbar"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
@@ -1972,17 +1982,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
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.
|
||||
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],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
...toolbarContentSlot(theme.spacing[2]),
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
modelControl: {
|
||||
minWidth: 0,
|
||||
@@ -1994,8 +2007,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
modeIconBadge: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
...toolbarSlot(TOUCH_ICON_SIZE.md),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 0,
|
||||
@@ -2007,7 +2019,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
modeBadgePressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
disabledBadge: {
|
||||
opacity: 0.5,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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 {
|
||||
glyphSize: number;
|
||||
glyphs: ToolbarGlyphs;
|
||||
presentation: ComposerControlPresentation;
|
||||
}
|
||||
|
||||
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
|
||||
glyphSize: 16,
|
||||
glyphs: toolbarGlyphsFor("pointer"),
|
||||
presentation: resolveComposerControlPresentation("full"),
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
composerToolbarGeometry,
|
||||
resolveComposerControlDensity,
|
||||
resolveComposerControlPresentation,
|
||||
resolveModelTriggerLabels,
|
||||
} from "./layout";
|
||||
|
||||
const POINTER = composerToolbarGeometry("pointer");
|
||||
@@ -174,21 +175,29 @@ describe("composer control layout", () => {
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("gives every toolbar control one shell and one glyph per control sizing", () => {
|
||||
it("gives every toolbar control one shell and one glyph per role and control sizing", () => {
|
||||
const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" };
|
||||
expect(POINTER).toEqual({
|
||||
controlSize: 28,
|
||||
glyphSize: 16,
|
||||
glyphs: { icon: pointerGlyph, label: pointerGlyph },
|
||||
controlGap: 4,
|
||||
iconLabelGap: 4,
|
||||
labelPadding: 8,
|
||||
caretSize: 14,
|
||||
});
|
||||
expect(TOUCH).toMatchObject({ controlSize: 44, glyphSize: 20 });
|
||||
expect(TOUCH).toMatchObject({
|
||||
controlSize: 20,
|
||||
glyphs: {
|
||||
icon: { size: 20, strokeWidth: 1.5, tone: "foreground" },
|
||||
label: { size: 20, strokeWidth: 1.5, tone: "foreground" },
|
||||
},
|
||||
controlGap: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
|
||||
const input = {
|
||||
availableWidth: 420,
|
||||
availableWidth: 400,
|
||||
currentDensity: "full" as const,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
@@ -204,8 +213,8 @@ describe("composer control layout", () => {
|
||||
});
|
||||
|
||||
describe("touch toolbar on an iPad in portrait, sidebar docked", () => {
|
||||
// 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.
|
||||
// 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.
|
||||
const controls = {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
@@ -217,7 +226,7 @@ describe("composer control layout", () => {
|
||||
it("keeps the model family label with icon-only controls on an empty draft", () => {
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 272,
|
||||
availableWidth: 280,
|
||||
currentDensity: "full",
|
||||
geometry: TOUCH,
|
||||
controls,
|
||||
@@ -225,10 +234,21 @@ describe("composer control layout", () => {
|
||||
).toBe("tight");
|
||||
});
|
||||
|
||||
it("folds thinking, mode and features into the model control once an attachment narrows it", () => {
|
||||
it("keeps the icon-only controls once an attachment brings in send", () => {
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 224,
|
||||
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,
|
||||
currentDensity: "tight",
|
||||
geometry: TOUCH,
|
||||
controls,
|
||||
@@ -239,7 +259,7 @@ describe("composer control layout", () => {
|
||||
it("drops the model label only after the controls are folded", () => {
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 120,
|
||||
availableWidth: 64,
|
||||
currentDensity: "compact",
|
||||
geometry: TOUCH,
|
||||
controls,
|
||||
@@ -263,8 +283,58 @@ describe("composer control layout", () => {
|
||||
};
|
||||
|
||||
expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact");
|
||||
// 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");
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_GAPS,
|
||||
TOOLBAR_CONTROL_SIZES,
|
||||
toolbarGlyphsFor,
|
||||
type ControlSizing,
|
||||
type ToolbarGlyphs,
|
||||
} from "@/components/ui/control-geometry";
|
||||
|
||||
/**
|
||||
@@ -39,23 +41,32 @@ 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;
|
||||
glyphSize: number;
|
||||
glyphs: ToolbarGlyphs;
|
||||
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`. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
|
||||
const glyphs = toolbarGlyphsFor(sizing);
|
||||
const isTouch = sizing === "touch";
|
||||
return {
|
||||
controlSize: TOOLBAR_CONTROL_SIZES[sizing],
|
||||
glyphSize: iconSizeFor("md")[sizing],
|
||||
controlGap: 4,
|
||||
controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer,
|
||||
glyphs,
|
||||
controlGap: TOOLBAR_CONTROL_GAPS[sizing],
|
||||
iconLabelGap: 4,
|
||||
labelPadding: 8,
|
||||
labelPadding: isTouch ? 0 : 8,
|
||||
caretSize: 14,
|
||||
};
|
||||
}
|
||||
@@ -63,8 +74,6 @@ 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;
|
||||
@@ -83,7 +92,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.glyphSize + geometry.iconLabelGap;
|
||||
return geometry.labelPadding * 2 + geometry.glyphs.label.size + geometry.iconLabelGap;
|
||||
}
|
||||
|
||||
function resolveFeatureControlWidth(
|
||||
@@ -147,16 +156,16 @@ function resolveTightFloor(
|
||||
return sumControlWidths(widths, geometry);
|
||||
}
|
||||
|
||||
/** One model control carrying the model family label and the thinking label. */
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
function resolveCompactFloor(
|
||||
controls: ComposerControlPresence,
|
||||
geometry: ComposerToolbarGeometry,
|
||||
): number {
|
||||
const fontScale = normalizedFontScale(controls.fontScale);
|
||||
const thinkingLabel = controls.hasThinking
|
||||
? geometry.iconLabelGap + THINKING_LABEL_WIDTH * fontScale
|
||||
: 0;
|
||||
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale + thinkingLabel;
|
||||
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale;
|
||||
}
|
||||
|
||||
function resolveDensityFloor(
|
||||
@@ -251,3 +260,33 @@ 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, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
@@ -85,7 +85,6 @@ export function AgentModeControl({
|
||||
surface = "toolbar",
|
||||
onClose,
|
||||
}: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { presentation } = useComposerControlLayout();
|
||||
const { t } = useTranslation();
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
@@ -102,7 +101,6 @@ 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[]>(
|
||||
@@ -197,7 +195,6 @@ export function AgentModeControl({
|
||||
<AgentControlTrigger
|
||||
ref={anchorRef}
|
||||
icon={Icon}
|
||||
iconColor={iconColor}
|
||||
surface={surface}
|
||||
label={t("agentControls.mode.title")}
|
||||
value={selectedModeLabel}
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
Keyboard,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
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 { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { readMeasuredWidth } from "@/hooks/use-container-width";
|
||||
import {
|
||||
TOUCH_ICON_SIZE,
|
||||
toolbarContentSlot,
|
||||
toolbarSlot,
|
||||
type ToolbarGlyphs,
|
||||
} from "@/components/ui/control-geometry";
|
||||
|
||||
const SNAP_POINTS = ["80%", "90%"];
|
||||
const MODEL_LIST_TOP_INSET = 4;
|
||||
@@ -42,7 +57,7 @@ interface CompactModelSheetProps {
|
||||
isRetryingProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
glyphSize: number;
|
||||
glyphs: ToolbarGlyphs;
|
||||
/** False when the toolbar is too narrow for labels: the trigger shows the provider glyph only. */
|
||||
showLabels: boolean;
|
||||
canSwitchProvider: boolean;
|
||||
@@ -78,6 +93,43 @@ 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,
|
||||
@@ -96,7 +148,7 @@ export function CompactModelSheet({
|
||||
isRetryingProvider = false,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
glyphSize,
|
||||
glyphs,
|
||||
showLabels,
|
||||
canSwitchProvider,
|
||||
children,
|
||||
@@ -233,17 +285,6 @@ 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">
|
||||
@@ -269,36 +310,19 @@ export function CompactModelSheet({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComboboxTrigger
|
||||
collapsable={false}
|
||||
<ModelSheetTrigger
|
||||
ModelIcon={hasSelectedProvider ? ModelIcon : null}
|
||||
modelLabel={shortModelLabel(rootBrowser.triggerLabel)}
|
||||
thinkingLabel={thinkingLabel}
|
||||
glyphs={glyphs}
|
||||
iconOnly={!showLabels && hasSelectedProvider}
|
||||
isOpen={isOpen}
|
||||
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}
|
||||
@@ -380,20 +404,137 @@ 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],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
...toolbarContentSlot(theme.spacing[2]),
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
triggerIconOnly: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
...toolbarSlot(TOUCH_ICON_SIZE.md),
|
||||
flexShrink: 0,
|
||||
paddingHorizontal: 0,
|
||||
justifyContent: "center",
|
||||
@@ -402,7 +543,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
triggerPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: 0.5,
|
||||
@@ -421,6 +562,28 @@ 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,
|
||||
@@ -430,6 +593,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
providerIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
providerIconForeground: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
sheetBody: {
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: 0,
|
||||
|
||||
@@ -33,10 +33,16 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
controlSizingForLayout,
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
TOOLBAR_CONTROL_GAP,
|
||||
TOOLBAR_DISC_GLYPH_SIZES,
|
||||
TOOLBAR_DISC_SIZE,
|
||||
TOOLBAR_RINGS,
|
||||
toolbarGlyphsFor,
|
||||
toolbarGlyphTone,
|
||||
type ToolbarGlyph,
|
||||
toolbarSlot,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { COMPOSER_TOOLBAR_SHAPES } from "@/composer/input/toolbar-rails";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
@@ -260,20 +266,22 @@ function resolveCheckoutRemoteUrl(
|
||||
return checkoutStatus?.remoteUrl ?? null;
|
||||
}
|
||||
|
||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||
function buildCancelButtonStyle(
|
||||
pressed: boolean,
|
||||
isConnected: boolean,
|
||||
isCancellingAgent: boolean,
|
||||
): object[] {
|
||||
const pressedStyle = pressed ? styles.discPressed : undefined;
|
||||
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||
return [styles.cancelButton, pressedStyle, disabled].filter((value): value is object =>
|
||||
Boolean(value),
|
||||
);
|
||||
}
|
||||
|
||||
function buildRealtimeVoiceButtonStyle(
|
||||
hovered: boolean | undefined,
|
||||
voiceButtonDisabled: boolean,
|
||||
reserveLeadingSpace: boolean,
|
||||
): object[] {
|
||||
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
|
||||
function buildRealtimeVoiceButtonStyle(pressed: boolean, voiceButtonDisabled: boolean): object[] {
|
||||
const pressedStyle = pressed ? styles.discPressed : undefined;
|
||||
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
|
||||
const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined;
|
||||
return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter(
|
||||
return [styles.realtimeVoiceButton, pressedStyle, disabledStyle].filter(
|
||||
(value): value is object => Boolean(value),
|
||||
);
|
||||
}
|
||||
@@ -1050,10 +1058,7 @@ function ComposerAutocompleteBinding({
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={onSelect}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
status={autocomplete.status}
|
||||
>
|
||||
{children}
|
||||
</ComposerAutocomplete>
|
||||
@@ -1082,8 +1087,8 @@ function ComposerForgeBinding({
|
||||
}
|
||||
|
||||
interface ComposerCancelButtonProps {
|
||||
buttonIconSize: number;
|
||||
cancelButtonStyle: (object | undefined)[];
|
||||
discGlyphSize: number;
|
||||
cancelButtonStyle: (state: PressableStateCallbackType) => object[];
|
||||
handleCancelAgent: () => void;
|
||||
isConnected: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
@@ -1092,7 +1097,7 @@ interface ComposerCancelButtonProps {
|
||||
}
|
||||
|
||||
function ComposerCancelButton({
|
||||
buttonIconSize,
|
||||
discGlyphSize,
|
||||
cancelButtonStyle,
|
||||
handleCancelAgent,
|
||||
isConnected,
|
||||
@@ -1106,7 +1111,7 @@ function ComposerCancelButton({
|
||||
const icon = isCancellingAgent ? (
|
||||
<LoadingSpinner size="small" color="white" />
|
||||
) : (
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
<Square size={discGlyphSize} color="white" fill="white" />
|
||||
);
|
||||
const shortcutNode = agentInterruptKeys ? <Shortcut chord={agentInterruptKeys} /> : null;
|
||||
return (
|
||||
@@ -1131,7 +1136,7 @@ function ComposerCancelButton({
|
||||
}
|
||||
|
||||
interface ComposerVoiceModeButtonProps {
|
||||
buttonIconSize: number;
|
||||
glyph: ToolbarGlyph;
|
||||
handleToggleRealtimeVoice: () => void;
|
||||
isConnected: boolean;
|
||||
isVoiceSwitching: boolean;
|
||||
@@ -1142,37 +1147,25 @@ interface ComposerVoiceModeButtonProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
|
||||
function showsVoiceModeButton(input: {
|
||||
isVoiceModeForAgent: boolean;
|
||||
hasAgent: boolean;
|
||||
isAgentRunning: boolean;
|
||||
hasSendableContent: boolean;
|
||||
isCompact: boolean;
|
||||
showVoice: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}): boolean {
|
||||
// Voice mode holds the trailing disc until there is something to send, then Send takes it.
|
||||
return (
|
||||
<View style={styles.rightControls}>
|
||||
<ComposerVoiceModeButton {...voiceProps} />
|
||||
</View>
|
||||
input.showVoice &&
|
||||
!input.isVoiceModeForAgent &&
|
||||
input.hasAgent &&
|
||||
!input.isAgentRunning &&
|
||||
!input.hasSendableContent
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerVoiceModeButton({
|
||||
buttonIconSize,
|
||||
glyph,
|
||||
handleToggleRealtimeVoice,
|
||||
isConnected,
|
||||
isVoiceSwitching,
|
||||
@@ -1183,13 +1176,25 @@ function ComposerVoiceModeButton({
|
||||
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
|
||||
const renderTriggerContent = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (isVoiceSwitching) {
|
||||
return <LoadingSpinner size="small" color="white" />;
|
||||
}
|
||||
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
|
||||
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
|
||||
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>
|
||||
);
|
||||
},
|
||||
[buttonIconSize, isVoiceSwitching],
|
||||
[glyph, isVoiceSwitching],
|
||||
);
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -1311,7 +1316,9 @@ function ComposerContentImpl({
|
||||
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)];
|
||||
const controlSizing = controlSizingForLayout(isCompactFormFactor);
|
||||
const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon;
|
||||
const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing];
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const { finePointer: hasFinePointer } = usePointerCapabilities();
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
@@ -2012,8 +2019,9 @@ function ComposerContentImpl({
|
||||
[agentId, recall, replaceUserInput, serverId],
|
||||
);
|
||||
|
||||
const cancelButtonStyle = useMemo(
|
||||
() => buildCancelButtonStyle(isConnected, isCancellingAgent),
|
||||
const cancelButtonStyle = useCallback(
|
||||
(state: PressableStateCallbackType) =>
|
||||
buildCancelButtonStyle(state.pressed, isConnected, isCancellingAgent),
|
||||
[isConnected, isCancellingAgent],
|
||||
);
|
||||
|
||||
@@ -2021,14 +2029,14 @@ function ComposerContentImpl({
|
||||
const voiceButtonDisabled = !isConnected || isVoiceSwitching;
|
||||
const realtimeVoiceButtonStyle = useCallback(
|
||||
(state: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout),
|
||||
[isCompactLayout, voiceButtonDisabled],
|
||||
buildRealtimeVoiceButtonStyle(state.pressed, voiceButtonDisabled),
|
||||
[voiceButtonDisabled],
|
||||
);
|
||||
|
||||
const activeActionContent = useMemo(
|
||||
() => (
|
||||
<ComposerCancelButton
|
||||
buttonIconSize={buttonIconSize}
|
||||
discGlyphSize={discGlyphSize}
|
||||
cancelButtonStyle={cancelButtonStyle}
|
||||
handleCancelAgent={handleCancelAgent}
|
||||
isConnected={isConnected}
|
||||
@@ -2039,7 +2047,7 @@ function ComposerContentImpl({
|
||||
),
|
||||
[
|
||||
agentInterruptKeys,
|
||||
buttonIconSize,
|
||||
discGlyphSize,
|
||||
cancelButtonStyle,
|
||||
handleCancelAgent,
|
||||
isCancellingAgent,
|
||||
@@ -2048,32 +2056,34 @@ function ComposerContentImpl({
|
||||
],
|
||||
);
|
||||
|
||||
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}
|
||||
/>
|
||||
),
|
||||
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,
|
||||
[
|
||||
buttonIconSize,
|
||||
toolbarGlyph,
|
||||
handleToggleRealtimeVoice,
|
||||
hasAgent,
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isCompactLayout,
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
mode.showVoice,
|
||||
@@ -2093,7 +2103,7 @@ function ComposerContentImpl({
|
||||
maxTokens={agentState.contextWindowMaxTokens}
|
||||
usedTokens={agentState.contextWindowUsedTokens}
|
||||
totalCostUsd={agentState.totalCostUsd}
|
||||
glyphSize={buttonIconSize}
|
||||
toolbarRing={TOOLBAR_RINGS[controlSizing]}
|
||||
/>
|
||||
</View>
|
||||
) : null,
|
||||
@@ -2104,7 +2114,7 @@ function ComposerContentImpl({
|
||||
agentState.contextWindowMaxTokens,
|
||||
agentState.contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
buttonIconSize,
|
||||
controlSizing,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2490,7 +2500,7 @@ function ComposerContentImpl({
|
||||
disabled={isSubmitLoading}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceModeContent={voiceModeContent}
|
||||
activeActionContent={activeActionContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
@@ -2596,13 +2606,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
width: "100%",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
|
||||
cancelButton: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
cancelButtonDisc: {
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
@@ -2615,31 +2622,34 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
rightControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
gap: TOOLBAR_CONTROL_GAP,
|
||||
},
|
||||
contextWindowMeterSlot: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.ring),
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
voiceModeDisc: {
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButtonCompactReserve: {
|
||||
marginLeft: theme.spacing[1],
|
||||
realtimeVoiceButton: {
|
||||
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc),
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButtonActive: {
|
||||
backgroundColor: theme.colors.palette.green[600],
|
||||
borderColor: theme.colors.palette.green[800],
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
discPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
attachmentTray: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -31,10 +31,24 @@ import { useTranslation } from "react-i18next";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
controlSizingForLayout,
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_GAP,
|
||||
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";
|
||||
@@ -170,8 +184,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;
|
||||
/** Auxiliary content to render on the right side after the voice button. */
|
||||
rightContent?: React.ReactNode;
|
||||
/** The realtime voice mode button after the dictation button, or null when the row hides it. */
|
||||
voiceModeContent?: React.ReactNode;
|
||||
/** Primary action to render when the agent is active and the composer has no sendable content. */
|
||||
activeActionContent?: React.ReactNode;
|
||||
voiceServerId?: string;
|
||||
@@ -252,19 +266,28 @@ interface TextAreaHandle {
|
||||
};
|
||||
}
|
||||
|
||||
function glyphColorMapping(glyph: ToolbarGlyph, hovered: boolean) {
|
||||
return toolbarGlyphTone(glyph, hovered) === "foreground"
|
||||
? iconForegroundMapping
|
||||
: iconForegroundMutedMapping;
|
||||
}
|
||||
|
||||
function AttachButtonIcon({
|
||||
hovered,
|
||||
onAttachButtonRef,
|
||||
buttonIconSize,
|
||||
glyph,
|
||||
}: {
|
||||
hovered: boolean;
|
||||
onAttachButtonRef: ((node: View | null) => void) | undefined;
|
||||
buttonIconSize: number;
|
||||
glyph: ToolbarGlyph;
|
||||
}) {
|
||||
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
|
||||
return (
|
||||
<View ref={onAttachButtonRef} collapsable={false} style={styles.attachButtonAnchor}>
|
||||
<ThemedPlus size={buttonIconSize} uniProps={colorMapping} />
|
||||
<ThemedPlus
|
||||
size={glyph.size}
|
||||
strokeWidth={glyph.strokeWidth}
|
||||
uniProps={glyphColorMapping(glyph, hovered)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -342,25 +365,25 @@ function VoiceButtonIcon({
|
||||
hovered,
|
||||
isDictating,
|
||||
isMutedRealtime,
|
||||
buttonIconSize,
|
||||
glyph,
|
||||
discGlyphSize,
|
||||
}: {
|
||||
hovered: boolean;
|
||||
isDictating: boolean;
|
||||
isMutedRealtime: boolean;
|
||||
buttonIconSize: number;
|
||||
glyph: ToolbarGlyph;
|
||||
discGlyphSize: number;
|
||||
}) {
|
||||
if (isDictating) {
|
||||
return (
|
||||
<View style={styles.voiceButtonRecordingDisc}>
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
<Square size={discGlyphSize} color="white" fill="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
|
||||
if (isMutedRealtime) {
|
||||
return <ThemedMicOff size={buttonIconSize} uniProps={colorMapping} />;
|
||||
}
|
||||
return <ThemedMic size={buttonIconSize} uniProps={colorMapping} />;
|
||||
const colorMapping = glyphColorMapping(glyph, hovered);
|
||||
const Icon = isMutedRealtime ? ThemedMicOff : ThemedMic;
|
||||
return <Icon size={glyph.size} strokeWidth={glyph.strokeWidth} uniProps={colorMapping} />;
|
||||
}
|
||||
|
||||
type ShortcutChord = NonNullable<React.ComponentProps<typeof Shortcut>["chord"]>;
|
||||
@@ -399,12 +422,12 @@ function SendButtonContent({
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
submitLabel,
|
||||
buttonIconSize,
|
||||
glyph,
|
||||
}: {
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
submitLabel: string | undefined;
|
||||
buttonIconSize: number;
|
||||
glyph: ToolbarGlyph;
|
||||
}) {
|
||||
if (isSubmitLoading) {
|
||||
return <ThemedLoadingSpinner size="small" uniProps={iconAccentForegroundMapping} />;
|
||||
@@ -413,9 +436,21 @@ function SendButtonContent({
|
||||
return <Text style={styles.sendButtonLabel}>{submitLabel}</Text>;
|
||||
}
|
||||
if (submitIcon === "return") {
|
||||
return <ThemedCornerDownLeft size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
return (
|
||||
<ThemedCornerDownLeft
|
||||
size={glyph.size}
|
||||
strokeWidth={glyph.strokeWidth}
|
||||
uniProps={iconAccentForegroundMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
return (
|
||||
<ThemedArrowUp
|
||||
size={glyph.size}
|
||||
strokeWidth={glyph.strokeWidth}
|
||||
uniProps={iconAccentForegroundMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DesktopKeyPressContext {
|
||||
@@ -808,7 +843,7 @@ function SendButtonTooltip({
|
||||
submitIcon,
|
||||
submitLabel,
|
||||
submitButtonTestID,
|
||||
buttonIconSize,
|
||||
glyph,
|
||||
sendKeys,
|
||||
sendTooltipLabel,
|
||||
}: {
|
||||
@@ -825,7 +860,7 @@ function SendButtonTooltip({
|
||||
submitIcon: "arrow" | "return";
|
||||
submitLabel: string | undefined;
|
||||
submitButtonTestID: string | undefined;
|
||||
buttonIconSize: number;
|
||||
glyph: ToolbarGlyph;
|
||||
sendKeys: ShortcutChord | null | undefined;
|
||||
sendTooltipLabel: string;
|
||||
}) {
|
||||
@@ -847,7 +882,7 @@ function SendButtonTooltip({
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
buttonIconSize={buttonIconSize}
|
||||
glyph={glyph}
|
||||
/>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
@@ -1140,7 +1175,7 @@ interface ResolvedMessageInputProps {
|
||||
disabled: boolean;
|
||||
leftContent: React.ReactNode;
|
||||
beforeVoiceContent: React.ReactNode;
|
||||
rightContent: React.ReactNode;
|
||||
voiceModeContent: React.ReactNode;
|
||||
activeActionContent: React.ReactNode;
|
||||
voiceServerId: string | undefined;
|
||||
voiceAgentId: string | undefined;
|
||||
@@ -1188,7 +1223,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
disabled: props.disabled ?? false,
|
||||
leftContent: props.leftContent,
|
||||
beforeVoiceContent: props.beforeVoiceContent,
|
||||
rightContent: props.rightContent,
|
||||
voiceModeContent: props.voiceModeContent,
|
||||
activeActionContent: props.activeActionContent,
|
||||
voiceServerId: props.voiceServerId,
|
||||
voiceAgentId: props.voiceAgentId,
|
||||
@@ -1216,8 +1251,12 @@ 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: {
|
||||
fullscreen: boolean;
|
||||
standsIn: boolean;
|
||||
disabled: boolean;
|
||||
hasText: boolean;
|
||||
attachments: ComposerAttachment[];
|
||||
@@ -1225,7 +1264,7 @@ function resolvePresentationSendDisabled(input: {
|
||||
allowEmpty: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input))
|
||||
input.disabled || (input.standsIn && !input.allowEmpty && !hasSendableComposerContent(input))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1266,7 +1305,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
disabled,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
voiceModeContent,
|
||||
activeActionContent,
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
@@ -1296,7 +1335,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const [renderedLines, setRenderedLines] = useState(1);
|
||||
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
|
||||
const controlSizing = controlSizingForLayout(isCompact);
|
||||
const buttonIconSize = iconSizeFor("md")[controlSizing];
|
||||
const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon;
|
||||
const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing];
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
||||
@@ -1919,18 +1959,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
);
|
||||
|
||||
const attachButtonStyle = useCallback(
|
||||
({ hovered }: { hovered?: boolean }) => [
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||
styles.attachButton,
|
||||
Boolean(hovered) && styles.iconButtonHovered,
|
||||
Boolean(hovered || pressed) && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
],
|
||||
[isConnected, disabled],
|
||||
);
|
||||
|
||||
const voiceButtonStyle = useCallback(
|
||||
({ hovered }: { hovered?: boolean }) => [
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||
styles.voiceButton,
|
||||
Boolean(hovered) && !isDictating && styles.iconButtonHovered,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
Boolean(hovered || pressed) && !isDictating && styles.iconButtonHovered,
|
||||
Boolean(pressed) && isDictating && styles.discPressed,
|
||||
!isDictationStartEnabled && styles.buttonDisabled,
|
||||
],
|
||||
[isDictating, isDictationStartEnabled],
|
||||
@@ -1971,16 +2013,24 @@ 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({
|
||||
fullscreen: isFullscreen,
|
||||
standsIn: sendStandsIn,
|
||||
disabled: isSendButtonDisabled,
|
||||
hasText: hasLiveText,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmpty: allowEmptySubmit,
|
||||
});
|
||||
const sendButtonCombinedStyle = useMemo(
|
||||
() => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled],
|
||||
const sendButtonCombinedStyle = useCallback(
|
||||
({ pressed }: { pressed?: boolean }) => [
|
||||
styles.sendButton,
|
||||
Boolean(pressed) && styles.discPressed,
|
||||
isPresentedSendDisabled && styles.buttonDisabled,
|
||||
],
|
||||
[isPresentedSendDisabled],
|
||||
);
|
||||
const overlayContainerStyle = useMemo(
|
||||
@@ -1993,10 +2043,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<AttachButtonIcon
|
||||
hovered={Boolean(hovered)}
|
||||
onAttachButtonRef={onAttachButtonRef}
|
||||
buttonIconSize={buttonIconSize}
|
||||
glyph={toolbarGlyph}
|
||||
/>
|
||||
),
|
||||
[onAttachButtonRef, buttonIconSize],
|
||||
[onAttachButtonRef, toolbarGlyph],
|
||||
);
|
||||
|
||||
const renderVoiceButtonIcon = useCallback(
|
||||
@@ -2005,16 +2055,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
hovered={Boolean(hovered)}
|
||||
isDictating={isDictating}
|
||||
isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)}
|
||||
buttonIconSize={buttonIconSize}
|
||||
glyph={toolbarGlyph}
|
||||
discGlyphSize={discGlyphSize}
|
||||
/>
|
||||
),
|
||||
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
|
||||
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, toolbarGlyph, discGlyphSize],
|
||||
);
|
||||
|
||||
const sendButton = useMemo(
|
||||
() => (
|
||||
<PrimaryAction
|
||||
kind={isFullscreen ? "send" : primaryActionKind}
|
||||
kind={presentedActionKind}
|
||||
activeActionContent={activeActionContent}
|
||||
shouldShow
|
||||
canPressLoadingButton={canPressLoadingButton}
|
||||
@@ -2029,14 +2080,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
buttonIconSize={buttonIconSize}
|
||||
glyph={toolbarGlyph}
|
||||
sendKeys={resolveSendKeys(sendShortcut)}
|
||||
sendTooltipLabel={sendTooltipLabel}
|
||||
/>
|
||||
),
|
||||
[
|
||||
isFullscreen,
|
||||
primaryActionKind,
|
||||
presentedActionKind,
|
||||
activeActionContent,
|
||||
canPressLoadingButton,
|
||||
onSubmitLoadingPress,
|
||||
@@ -2050,7 +2100,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
submitIcon,
|
||||
submitLabel,
|
||||
submitButtonTestID,
|
||||
buttonIconSize,
|
||||
toolbarGlyph,
|
||||
sendShortcut,
|
||||
sendTooltipLabel,
|
||||
],
|
||||
@@ -2124,9 +2174,25 @@ 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={styles.buttonRow}>
|
||||
<View style={buttonRowStyle}>
|
||||
{/* Toolbar left: attachment button + agent controls */}
|
||||
<View style={styles.leftButtonGroup}>
|
||||
<AttachmentDropdown
|
||||
@@ -2156,12 +2222,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
voiceMuteToggleKeys={voiceMuteToggleKeys}
|
||||
dictationToggleKeys={dictationToggleKeys}
|
||||
/>
|
||||
{rightContent}
|
||||
{voiceModeContent}
|
||||
{sendButton}
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
buttonRowStyle,
|
||||
mode.showAttachments,
|
||||
isConnected,
|
||||
disabled,
|
||||
@@ -2181,7 +2248,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
voiceMuteToggleKeys,
|
||||
dictationToggleKeys,
|
||||
rightContent,
|
||||
voiceModeContent,
|
||||
sendButton,
|
||||
],
|
||||
);
|
||||
@@ -2249,7 +2316,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<ComposerInputPresentation
|
||||
fullscreen={isFullscreen}
|
||||
controlSizing={controlSizing}
|
||||
sendIconSize={buttonIconSize}
|
||||
sendIconSize={toolbarGlyph.size}
|
||||
surface={textSurface}
|
||||
toolbar={toolbar}
|
||||
send={sendButton}
|
||||
@@ -2290,20 +2357,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
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],
|
||||
},
|
||||
borderRadius: COMPOSER_CORNER_RADIUS,
|
||||
paddingTop: COMPOSER_PADDING_TOP,
|
||||
paddingBottom: COMPOSER_PADDING_BOTTOM,
|
||||
paddingHorizontal: COMPOSER_PADDING_HORIZONTAL,
|
||||
...(isWeb
|
||||
? {
|
||||
transitionProperty: "border-color",
|
||||
@@ -2377,47 +2434,49 @@ 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: "flex-end",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginHorizontal: -6,
|
||||
},
|
||||
leftButtonGroup: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[0],
|
||||
alignItems: "center",
|
||||
gap: TOOLBAR_CONTROL_GAP,
|
||||
},
|
||||
rightButtonGroup: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
gap: TOOLBAR_CONTROL_GAP,
|
||||
},
|
||||
attachButton: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.attach),
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
attachButtonAnchor: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
voiceButton: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.dictation),
|
||||
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,
|
||||
@@ -2426,13 +2485,12 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
|
||||
sendButton: {
|
||||
minWidth: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
minWidth: TOOLBAR_DISC_SIZE,
|
||||
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sendButtonDisc: {
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
@@ -2459,6 +2517,9 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
discPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
MENU_ITEM_HEIGHT,
|
||||
@@ -16,6 +17,7 @@ import { StatusRing } from "@/components/status-ring";
|
||||
import { bySizing } from "@/components/ui/control-geometry";
|
||||
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { usePanelAwareHorizontalScrollGesture } from "@/mobile-panels/gestures";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
@@ -35,10 +37,23 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
|
||||
* move the transcript.
|
||||
*/
|
||||
export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
|
||||
const scrollGesture = usePanelAwareHorizontalScrollGesture();
|
||||
return (
|
||||
<View style={styles.bar} pointerEvents="box-none">
|
||||
<View style={styles.track} pointerEvents="box-none">
|
||||
{children}
|
||||
<View style={styles.trackViewport} pointerEvents="box-none">
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<ScrollView
|
||||
testID="composer-track-scroll"
|
||||
horizontal
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View style={styles.track} pointerEvents="box-none">
|
||||
{children}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -371,9 +386,14 @@ const styles = StyleSheet.create((theme) => {
|
||||
md: COMPOSER_PILL_CLEARANCE.wide,
|
||||
},
|
||||
},
|
||||
track: {
|
||||
trackViewport: {
|
||||
width: "100%",
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
minWidth: 0,
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
track: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
markWorkspaceArchiveUndoPending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
settleWorkspaceArchiveIntent,
|
||||
shouldSuppressWorkspaceRemovalForLocalUndo,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
const baseWorkspace: WorkspaceDescriptor = {
|
||||
@@ -116,3 +119,41 @@ 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,77 +1,142 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
|
||||
interface PendingWorkspaceArchive {
|
||||
workspaceId: string;
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
|
||||
const intentsByServer = new Map<string, Map<string, WorkspaceArchiveIntent>>();
|
||||
const intentListeners = new Set<() => void>();
|
||||
const targetByIntent = new WeakMap<
|
||||
WorkspaceArchiveIntent,
|
||||
{ serverId: string; workspaceId: string }
|
||||
>();
|
||||
|
||||
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
|
||||
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
|
||||
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);
|
||||
}
|
||||
|
||||
export function markWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): 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);
|
||||
}): WorkspaceArchiveIntent {
|
||||
return setIntent(input, "archive");
|
||||
}
|
||||
|
||||
/** The host removed the workspace, which settles whichever archive this client has pending. */
|
||||
export function clearWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): void {
|
||||
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);
|
||||
}
|
||||
clearIntentOfKind(input, "archive");
|
||||
}
|
||||
|
||||
export function isWorkspaceArchivePending(input: {
|
||||
export function isWorkspaceArchivePending(input: WorkspaceIntentTarget): boolean {
|
||||
return readIntent(input)?.kind === "archive";
|
||||
}
|
||||
|
||||
export function markWorkspaceArchiveUndoPending(input: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
}): boolean {
|
||||
const serverId = input.serverId.trim();
|
||||
if (!serverId) {
|
||||
return false;
|
||||
}
|
||||
workspaceId: string;
|
||||
}): WorkspaceArchiveIntent {
|
||||
return setIntent(input, "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 isWorkspaceArchiveUndoPending(input: WorkspaceIntentTarget): boolean {
|
||||
return readIntent(input)?.kind === "undo";
|
||||
}
|
||||
|
||||
export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
}): boolean {
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
});
|
||||
return isWorkspaceArchivePending({ serverId: input.serverId, workspaceId: input.workspace.id });
|
||||
}
|
||||
|
||||
export function shouldSuppressWorkspaceRemovalForLocalUndo(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): boolean {
|
||||
return isWorkspaceArchiveUndoPending(input);
|
||||
}
|
||||
|
||||
@@ -667,15 +667,14 @@ function ChangesRepositoryToolbar({
|
||||
isGitCheckout
|
||||
testID="changes-branch-switcher"
|
||||
/>
|
||||
{model.baseRef ? (
|
||||
{model.baseRef && model.isWorktree ? (
|
||||
<>
|
||||
<ThemedArrowRight size={14} uniProps={foregroundMutedIconColorMapping} />
|
||||
<ThemedArrowRight size={14} uniProps={extraMutedIconColorMapping} />
|
||||
<WorkspaceBaseBranchPicker
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
cwd={model.cwd}
|
||||
baseRef={model.baseRef}
|
||||
isWorktree={model.isWorktree}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -20,7 +20,6 @@ interface Props {
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
baseRef: string;
|
||||
isWorktree: boolean;
|
||||
}
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
@@ -31,13 +30,7 @@ function refLabel(ref: string): string {
|
||||
return ref.replace(/^refs\/(heads|remotes)\//, "");
|
||||
}
|
||||
|
||||
export function WorkspaceBaseBranchPicker({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
baseRef,
|
||||
isWorktree,
|
||||
}: Props) {
|
||||
export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -135,7 +128,7 @@ export function WorkspaceBaseBranchPicker({
|
||||
[t, isPending, error, isError, handleRetry],
|
||||
);
|
||||
|
||||
if (!supported || !isWorktree || !workspaceId) return <Text style={styles.text}>{label}</Text>;
|
||||
if (!supported || !workspaceId) return <Text style={styles.text}>{label}</Text>;
|
||||
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
|
||||
@@ -172,7 +165,9 @@ export function WorkspaceBaseBranchPicker({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
anchor: { flexShrink: 1, minWidth: 0 },
|
||||
// The branch name truncates first so a short base like "main" stays readable; the cap lets
|
||||
// a long base still truncate instead of overflowing the header.
|
||||
anchor: { flexShrink: 0, minWidth: 0, maxWidth: "40%" },
|
||||
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
|
||||
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
shouldClearAgentAttention,
|
||||
type AgentAttentionClearTrigger,
|
||||
} from "@/utils/agent-attention";
|
||||
import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createAgentAttentionController } from "@/utils/agent-attention";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
|
||||
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
|
||||
@@ -14,54 +12,48 @@ interface UseAgentAttentionClearParams {
|
||||
agentId: string | null | undefined;
|
||||
client: DaemonClient | null;
|
||||
isConnected: boolean;
|
||||
requiresAttention: boolean | null | undefined;
|
||||
isAgentDataReady: boolean;
|
||||
requiresAttention: boolean;
|
||||
attentionReason: AttentionReason;
|
||||
isScreenFocused: boolean;
|
||||
focusRequest: object | null;
|
||||
onError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
interface AgentAttentionClearController {
|
||||
clearOnInputFocus: () => void;
|
||||
clearOnPromptSend: () => void;
|
||||
clearOnAgentBlur: () => void;
|
||||
}
|
||||
|
||||
export function useAgentAttentionClear({
|
||||
agentId,
|
||||
client,
|
||||
isConnected,
|
||||
isAgentDataReady,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
isScreenFocused,
|
||||
focusRequest,
|
||||
onError,
|
||||
}: UseAgentAttentionClearParams): AgentAttentionClearController {
|
||||
const [isAppVisible, setIsAppVisible] = useState<boolean>(() => getIsAppActivelyVisible());
|
||||
const deferredFocusEntryClearRef = useRef(false);
|
||||
const prevRequiresAttentionRef = useRef(Boolean(requiresAttention));
|
||||
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppActivelyVisible());
|
||||
const prevScreenFocusedRef = useRef(false);
|
||||
const prevAppVisibleRef = useRef(getIsAppActivelyVisible());
|
||||
|
||||
const clearAttention = useCallback(
|
||||
(trigger: AgentAttentionClearTrigger) => {
|
||||
const resolvedAgentId = agentId?.trim();
|
||||
if (!client || !resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!shouldClearAgentAttention({
|
||||
agentId: resolvedAgentId,
|
||||
isConnected,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
trigger,
|
||||
hasDeferredFocusEntryClear: deferredFocusEntryClearRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deferredFocusEntryClearRef.current = false;
|
||||
client.clearAgentAttention(resolvedAgentId).catch(() => {});
|
||||
},
|
||||
[agentId, attentionReason, client, isConnected, requiresAttention],
|
||||
const clearAttention = useStableEvent(async (id: string) => {
|
||||
if (client) await client.clearAgentAttention(id);
|
||||
});
|
||||
const reportError = useStableEvent((error: unknown) => {
|
||||
// The timeline's reconnect status already explains a lost connection.
|
||||
if (error instanceof DaemonConnectionError && error.code === "DAEMON_CONNECTION_LOST") return;
|
||||
onError(error);
|
||||
});
|
||||
const controller = useMemo(
|
||||
() =>
|
||||
createAgentAttentionController({
|
||||
clear: async () => {
|
||||
if (agentId) await clearAttention(agentId);
|
||||
},
|
||||
onError: reportError,
|
||||
}),
|
||||
[agentId, clearAttention, reportError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,46 +82,28 @@ export function useAgentAttentionClear({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresAttention) {
|
||||
deferredFocusEntryClearRef.current = false;
|
||||
}
|
||||
}, [requiresAttention]);
|
||||
|
||||
useEffect(() => {
|
||||
const isActivelyViewed = isScreenFocused && isAppVisible;
|
||||
if (
|
||||
!prevRequiresAttentionRef.current &&
|
||||
Boolean(requiresAttention) &&
|
||||
prevActivelyViewedRef.current &&
|
||||
isActivelyViewed
|
||||
) {
|
||||
deferredFocusEntryClearRef.current = true;
|
||||
}
|
||||
prevRequiresAttentionRef.current = Boolean(requiresAttention);
|
||||
prevActivelyViewedRef.current = isActivelyViewed;
|
||||
}, [isAppVisible, isScreenFocused, requiresAttention]);
|
||||
|
||||
useEffect(() => {
|
||||
const enteredScreenFocus = !prevScreenFocusedRef.current && isScreenFocused && isAppVisible;
|
||||
const resumedIntoFocusedAgent = !prevAppVisibleRef.current && isAppVisible && isScreenFocused;
|
||||
|
||||
if (enteredScreenFocus || resumedIntoFocusedAgent) {
|
||||
clearAttention("focus-entry");
|
||||
}
|
||||
|
||||
prevScreenFocusedRef.current = isScreenFocused;
|
||||
prevAppVisibleRef.current = isAppVisible;
|
||||
}, [clearAttention, isAppVisible, isScreenFocused]);
|
||||
controller.update({
|
||||
focused: isScreenFocused && isAppVisible,
|
||||
ready: Boolean(client && agentId && isConnected && isAgentDataReady),
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
focusRequest,
|
||||
});
|
||||
}, [
|
||||
controller,
|
||||
client,
|
||||
agentId,
|
||||
isConnected,
|
||||
isAgentDataReady,
|
||||
isScreenFocused,
|
||||
isAppVisible,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
focusRequest,
|
||||
]);
|
||||
|
||||
return {
|
||||
clearOnInputFocus: useCallback(() => {
|
||||
clearAttention("input-focus");
|
||||
}, [clearAttention]),
|
||||
clearOnPromptSend: useCallback(() => {
|
||||
clearAttention("prompt-send");
|
||||
}, [clearAttention]),
|
||||
clearOnAgentBlur: useCallback(() => {
|
||||
clearAttention("agent-blur");
|
||||
}, [clearAttention]),
|
||||
clearOnInputFocus: controller.focus,
|
||||
clearOnPromptSend: controller.focus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import type { AutocompleteOption, AutocompleteStatus } from "@/components/ui/autocomplete";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
import {
|
||||
useAutocompleteCommands,
|
||||
type AgentSlashCommand,
|
||||
@@ -13,6 +14,9 @@ import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands";
|
||||
import { mergeSlashCommandSources } from "@/plugins/client-slash-commands/model";
|
||||
@@ -70,10 +74,8 @@ interface AgentAutocompleteResult {
|
||||
isVisible: boolean;
|
||||
options: AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
loadingText: string;
|
||||
emptyText: string;
|
||||
/** Shown in place of options; set exactly when `options` is empty. */
|
||||
status: AutocompleteStatus | null;
|
||||
onSelectOption: (option: AutocompleteOption, input?: AgentAutocompleteInputSnapshot) => void;
|
||||
onKeyPress: (event: AgentAutocompleteKeyPressEvent) => boolean;
|
||||
}
|
||||
@@ -162,39 +164,69 @@ function mapDirectorySuggestionsToEntries(payload: {
|
||||
}));
|
||||
}
|
||||
|
||||
function mapCommandToOption(entry: AvailableCommand, t: TFunction): AgentAutocompleteOption {
|
||||
const CLIENT_COMMAND_ICONS = {
|
||||
"archive-agent": "archive",
|
||||
"replace-agent-with-draft": "new-draft",
|
||||
} as const satisfies Record<ClientSlashCommand["kind"], AutocompleteOption["icon"]>;
|
||||
|
||||
interface CommandOptionContext {
|
||||
query: string;
|
||||
pluginNames: ReadonlyMap<string, string>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function mapCommandToOption(
|
||||
entry: AvailableCommand,
|
||||
context: CommandOptionContext,
|
||||
): AgentAutocompleteOption {
|
||||
const command = entry.command;
|
||||
const base = {
|
||||
id: command.name,
|
||||
label: `/${command.name}`,
|
||||
detail: command.argumentHint || undefined,
|
||||
description:
|
||||
entry.source === "client" ? t(entry.command.descriptionKey) : entry.command.description,
|
||||
kind: "command" as const,
|
||||
// The label carries a leading slash the query does not.
|
||||
labelMatches: findHighlightRanges(context.query, command.name).map((range) => ({
|
||||
start: range.start + 1,
|
||||
length: range.length,
|
||||
})),
|
||||
argumentHint: command.argumentHint || undefined,
|
||||
};
|
||||
if (entry.source === "client") {
|
||||
return {
|
||||
...base,
|
||||
type: "client_command",
|
||||
command: entry.command,
|
||||
icon: CLIENT_COMMAND_ICONS[entry.command.kind],
|
||||
description: context.t(entry.command.descriptionKey),
|
||||
tag: "Paseo",
|
||||
};
|
||||
}
|
||||
if (entry.source === "plugin") {
|
||||
return { ...base, type: "plugin_command", command: entry.command };
|
||||
return {
|
||||
...base,
|
||||
type: "plugin_command",
|
||||
command: entry.command,
|
||||
icon: "plugin",
|
||||
description: entry.command.description,
|
||||
tag: context.pluginNames.get(entry.command.pluginId) ?? entry.command.pluginId,
|
||||
};
|
||||
}
|
||||
const isSkill = entry.command.kind === "skill";
|
||||
return {
|
||||
...base,
|
||||
type: "provider_command",
|
||||
icon: isSkill ? "skill" : "command",
|
||||
description: entry.command.description,
|
||||
tag: isSkill ? context.t("agentAutocomplete.skillTag") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
type AutocompleteMode = "command" | "file" | null;
|
||||
|
||||
interface BuildAutocompleteOptionsInput {
|
||||
isVisible: boolean;
|
||||
mode: AutocompleteMode;
|
||||
commands: AgentSlashCommand[];
|
||||
pluginCommands: readonly PluginClientSlashCommand[];
|
||||
pluginNames: ReadonlyMap<string, string>;
|
||||
isDraftContext: boolean;
|
||||
commandFilterQuery: string;
|
||||
activeSlashCommand: SlashCommandRange | null;
|
||||
@@ -203,11 +235,7 @@ interface BuildAutocompleteOptionsInput {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
||||
if (!input.isVisible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildAutocompleteOptions(input: BuildAutocompleteOptionsInput): AgentAutocompleteOption[] {
|
||||
if (input.mode === "command") {
|
||||
const providerCommands = input.commands.map((command) => ({
|
||||
source: "provider" as const,
|
||||
@@ -236,8 +264,12 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
||||
availableCommands,
|
||||
input.commandFilterQuery,
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map((entry) => mapCommandToOption(entry, input.t));
|
||||
const context = {
|
||||
query: input.commandFilterQuery,
|
||||
pluginNames: input.pluginNames,
|
||||
t: input.t,
|
||||
};
|
||||
return orderAutocompleteOptions(matches).map((entry) => mapCommandToOption(entry, context));
|
||||
}
|
||||
|
||||
const activeFileMention = input.activeFileMention;
|
||||
@@ -247,7 +279,7 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
||||
type: "workspace_entry" as const,
|
||||
id: `${entry.kind}:${entry.path}`,
|
||||
label: entry.path,
|
||||
kind: entry.kind,
|
||||
icon: entry.kind,
|
||||
entryPath: entry.path,
|
||||
mention: activeFileMention,
|
||||
}));
|
||||
@@ -295,42 +327,140 @@ function resolveCanLoadCommands(args: {
|
||||
return Boolean(args.agentId) || args.isDraftContext;
|
||||
}
|
||||
|
||||
function resolveAutocompleteIsLoading(args: {
|
||||
mode: AutocompleteMode;
|
||||
isCommandsLoading: boolean;
|
||||
fileSuggestionsIsPending: boolean;
|
||||
fileSuggestionsIsLoading: boolean;
|
||||
optionsLength: number;
|
||||
}): boolean {
|
||||
if (args.mode === "command") {
|
||||
return args.isCommandsLoading && args.optionsLength === 0;
|
||||
}
|
||||
if (args.mode === "file") {
|
||||
return (
|
||||
args.fileSuggestionsIsPending || (args.fileSuggestionsIsLoading && args.optionsLength === 0)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
type CommandsState = "ready" | "loading" | "error";
|
||||
|
||||
function resolveCommandsState(input: {
|
||||
canListCommands: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}): CommandsState {
|
||||
if (!input.canListCommands) return "ready";
|
||||
if (input.isError) return "error";
|
||||
if (input.isLoading) return "loading";
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function resolveAutocompleteErrorMessage(args: {
|
||||
mode: AutocompleteMode;
|
||||
isCommandError: boolean;
|
||||
commandError: Error | null;
|
||||
fileSuggestionsError: unknown;
|
||||
interface AutocompleteMenu {
|
||||
options: AgentAutocompleteOption[];
|
||||
status: AutocompleteStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One state for the open menu, so options and a loading or error row cannot both claim it:
|
||||
* while a status row shows there are no options for Enter or Tab to pick.
|
||||
*/
|
||||
function resolveCommandMenu(input: {
|
||||
commandsState: CommandsState;
|
||||
options: () => AgentAutocompleteOption[];
|
||||
providerLabel: string | null;
|
||||
error: Error | null;
|
||||
onRetry: () => void;
|
||||
emptyText: string;
|
||||
t: TFunction;
|
||||
}): string | undefined {
|
||||
if (args.mode === "command") {
|
||||
return args.isCommandError
|
||||
? (args.commandError?.message ?? args.t("agentAutocomplete.failedToLoad"))
|
||||
: undefined;
|
||||
}): AutocompleteMenu {
|
||||
const { t } = input;
|
||||
if (input.commandsState === "loading") {
|
||||
const message = input.providerLabel
|
||||
? t("agentAutocomplete.loadingProviderCommands", { provider: input.providerLabel })
|
||||
: t("agentAutocomplete.loadingCommands");
|
||||
return { options: [], status: { kind: "loading", message } };
|
||||
}
|
||||
if (args.mode === "file") {
|
||||
return args.fileSuggestionsError instanceof Error
|
||||
? args.fileSuggestionsError.message
|
||||
: undefined;
|
||||
if (input.commandsState === "error") {
|
||||
const title = input.providerLabel
|
||||
? t("agentAutocomplete.providerCommandsFailed", { provider: input.providerLabel })
|
||||
: t("agentAutocomplete.commandsFailed");
|
||||
return {
|
||||
options: [],
|
||||
status: { kind: "error", title, reason: input.error?.message, onRetry: input.onRetry },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
return withEmptyStatus(input.options(), input.emptyText);
|
||||
}
|
||||
|
||||
function resolveFileMenu(input: {
|
||||
options: () => AgentAutocompleteOption[];
|
||||
isPending: boolean;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
t: TFunction;
|
||||
}): AutocompleteMenu {
|
||||
const options = input.options();
|
||||
if (input.isPending || (input.isLoading && options.length === 0)) {
|
||||
return {
|
||||
options: [],
|
||||
status: { kind: "loading", message: input.t("agentAutocomplete.searchingWorkspace") },
|
||||
};
|
||||
}
|
||||
if (input.error instanceof Error) {
|
||||
return {
|
||||
options: [],
|
||||
status: {
|
||||
kind: "error",
|
||||
title: input.t("agentAutocomplete.filesFailed"),
|
||||
reason: input.error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
return withEmptyStatus(options, input.t("agentAutocomplete.noFiles"));
|
||||
}
|
||||
|
||||
function withEmptyStatus(options: AgentAutocompleteOption[], emptyText: string): AutocompleteMenu {
|
||||
return options.length > 0
|
||||
? { options, status: null }
|
||||
: { options, status: { kind: "empty", message: emptyText } };
|
||||
}
|
||||
|
||||
/** How long a command menu waits for commands before it opens on a loading row. */
|
||||
const COMMANDS_LOADING_REVEAL_MS = 300;
|
||||
|
||||
/**
|
||||
* Whether the open command menu has waited long enough to show a loading row. Commands that
|
||||
* arrive sooner paint the list as the first frame; once the wait is over, loading (a retry)
|
||||
* shows at once so the open menu never closes on itself.
|
||||
*/
|
||||
function useLoadingRevealed(isMenuOpen: boolean): boolean {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isMenuOpen) {
|
||||
setRevealed(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setRevealed(true), COMMANDS_LOADING_REVEAL_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isMenuOpen]);
|
||||
return revealed;
|
||||
}
|
||||
|
||||
/** The names command rows and status rows show: the provider's label and each plugin's name. */
|
||||
function useCommandSourceNames(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
draftConfig: DraftCommandConfig | undefined;
|
||||
cwd: string;
|
||||
isCommandMenuOpen: boolean;
|
||||
commandsState: CommandsState;
|
||||
pluginCommandCount: number;
|
||||
}): { providerLabel: string | null; pluginNames: ReadonlyMap<string, string> } {
|
||||
const agentProvider = useSessionStore(
|
||||
(state) => state.sessions[input.serverId]?.agents?.get(input.agentId)?.provider,
|
||||
);
|
||||
const provider = input.draftConfig?.provider ?? agentProvider;
|
||||
// Only a loading or error row names the provider; only plugin rows need plugin names.
|
||||
const { entries: providerEntries } = useProvidersSnapshot(input.serverId, {
|
||||
cwd: input.cwd,
|
||||
enabled: input.isCommandMenuOpen && input.commandsState !== "ready",
|
||||
});
|
||||
const pluginList = usePluginList(input.serverId, {
|
||||
enabled: input.isCommandMenuOpen && input.pluginCommandCount > 0,
|
||||
});
|
||||
const pluginNames = useMemo(
|
||||
() => new Map((pluginList.data ?? []).map((plugin) => [plugin.id, pluginDisplayName(plugin)])),
|
||||
[pluginList.data],
|
||||
);
|
||||
return {
|
||||
providerLabel: provider ? resolveProviderLabel(provider, providerEntries) : null,
|
||||
pluginNames,
|
||||
};
|
||||
}
|
||||
|
||||
// A draft's agentId is a draft key the daemon does not know, so a draft lists
|
||||
@@ -345,14 +475,10 @@ function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAutocompleteEmptyText(args: {
|
||||
mode: AutocompleteMode;
|
||||
function resolveCommandsEmptyText(args: {
|
||||
draft: DraftCommandTarget | undefined;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (args.mode === "file") {
|
||||
return args.t("agentAutocomplete.noFiles");
|
||||
}
|
||||
if (args.draft?.status === "needs-project") {
|
||||
return args.t("agentAutocomplete.chooseProjectForCommands");
|
||||
}
|
||||
@@ -438,10 +564,25 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
||||
draftConfig: queryDraftConfig,
|
||||
});
|
||||
const { commands, isError, error } = commandsQuery;
|
||||
const isCommandsLoading = canListCommands && commandsQuery.isLoading;
|
||||
|
||||
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
|
||||
const commandsState = resolveCommandsState({
|
||||
canListCommands,
|
||||
isLoading: commandsQuery.isLoading,
|
||||
isError: commandsQuery.isError,
|
||||
});
|
||||
const isCommandMenuOpen = mode === "command" && canShowAutocomplete;
|
||||
const isLoadingRevealed = useLoadingRevealed(isCommandMenuOpen);
|
||||
const isVisible =
|
||||
canShowAutocomplete &&
|
||||
!(isCommandMenuOpen && commandsState === "loading" && !isLoadingRevealed);
|
||||
const { providerLabel, pluginNames } = useCommandSourceNames({
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig: queryDraftConfig,
|
||||
cwd: autocompleteCwd,
|
||||
isCommandMenuOpen,
|
||||
commandsState,
|
||||
pluginCommandCount: pluginClientSlashCommands.length,
|
||||
});
|
||||
|
||||
const fileSuggestionsQuery = useQuery({
|
||||
queryKey: [
|
||||
@@ -479,33 +620,67 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const options = useMemo<AgentAutocompleteOption[]>(
|
||||
() =>
|
||||
buildCommandAutocompleteOptions({
|
||||
const commandsEmptyText = resolveCommandsEmptyText({ draft, t });
|
||||
const retryCommandsQuery = commandsQuery.retry;
|
||||
const retryCommands = useCallback(() => {
|
||||
retryCommandsQuery();
|
||||
// Pressing Retry takes focus on web; hand it back so the user keeps typing the command.
|
||||
onAutocompleteApplied?.();
|
||||
}, [onAutocompleteApplied, retryCommandsQuery]);
|
||||
const { options, status } = useMemo<AutocompleteMenu>(() => {
|
||||
if (!isVisible) return { options: [], status: null };
|
||||
const buildOptions = () =>
|
||||
buildAutocompleteOptions({
|
||||
activeFileMention,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
commands: commandsQuery.commands,
|
||||
pluginCommands: pluginClientSlashCommands,
|
||||
pluginNames,
|
||||
activeSlashCommand,
|
||||
fileSuggestions: fileSuggestionsQuery.data ?? [],
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
t,
|
||||
}),
|
||||
[
|
||||
activeFileMention,
|
||||
activeSlashCommand,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
pluginClientSlashCommands,
|
||||
fileSuggestionsQuery.data,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
});
|
||||
if (mode === "file") {
|
||||
return resolveFileMenu({
|
||||
options: buildOptions,
|
||||
isPending: fileSuggestionsQuery.isPending,
|
||||
isLoading: fileSuggestionsQuery.isLoading,
|
||||
error: fileSuggestionsQuery.error,
|
||||
t,
|
||||
});
|
||||
}
|
||||
return resolveCommandMenu({
|
||||
commandsState,
|
||||
options: buildOptions,
|
||||
providerLabel,
|
||||
error: commandsQuery.error,
|
||||
onRetry: retryCommands,
|
||||
emptyText: commandsEmptyText,
|
||||
t,
|
||||
],
|
||||
);
|
||||
});
|
||||
}, [
|
||||
activeFileMention,
|
||||
activeSlashCommand,
|
||||
commandFilterQuery,
|
||||
commandsEmptyText,
|
||||
commandsQuery.commands,
|
||||
commandsQuery.error,
|
||||
commandsState,
|
||||
fileSuggestionsQuery.data,
|
||||
fileSuggestionsQuery.error,
|
||||
fileSuggestionsQuery.isLoading,
|
||||
fileSuggestionsQuery.isPending,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
pluginClientSlashCommands,
|
||||
pluginNames,
|
||||
providerLabel,
|
||||
retryCommands,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(option: AutocompleteOption, snapshot?: AgentAutocompleteInputSnapshot) => {
|
||||
@@ -587,35 +762,11 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const isLoading = resolveAutocompleteIsLoading({
|
||||
mode,
|
||||
isCommandsLoading,
|
||||
fileSuggestionsIsPending: fileSuggestionsQuery.isPending,
|
||||
fileSuggestionsIsLoading: fileSuggestionsQuery.isLoading,
|
||||
optionsLength: options.length,
|
||||
});
|
||||
const errorMessage = resolveAutocompleteErrorMessage({
|
||||
mode,
|
||||
isCommandError: isError,
|
||||
commandError: error,
|
||||
fileSuggestionsError: fileSuggestionsQuery.error,
|
||||
t,
|
||||
});
|
||||
|
||||
const loadingText =
|
||||
mode === "file"
|
||||
? t("agentAutocomplete.searchingWorkspace")
|
||||
: t("agentAutocomplete.loadingCommands");
|
||||
const emptyText = resolveAutocompleteEmptyText({ mode, draft, t });
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
options,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
loadingText,
|
||||
emptyText,
|
||||
status,
|
||||
onSelectOption,
|
||||
onKeyPress,
|
||||
};
|
||||
|
||||
@@ -85,6 +85,7 @@ export function useAggregatedAgents(options?: {
|
||||
serverLabel,
|
||||
title: agent.title ?? null,
|
||||
status: agent.status,
|
||||
backgroundWork: agent.backgroundWork,
|
||||
turn: agent.turn,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
getAutocompleteFallbackIndex,
|
||||
getAutocompleteNextIndex,
|
||||
@@ -31,38 +31,22 @@ export function useAutocomplete<
|
||||
TOption,
|
||||
TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent,
|
||||
>(input: UseAutocompleteInput<TOption, TKeyPressEvent>): UseAutocompleteResult<TKeyPressEvent> {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const previousQueryRef = useRef("");
|
||||
// The user's choice, tied to the query it was made for. The shown index is resolved from it
|
||||
// during render, so a list never paints its first frame without a selection: the popover only
|
||||
// measures once a non-empty list has one.
|
||||
const [choice, setChoice] = useState<{ index: number; query: string } | null>(null);
|
||||
const itemCount = input.isVisible ? input.options.length : 0;
|
||||
const selectedIndex = resolveSelectedIndex({
|
||||
choice,
|
||||
query: input.query,
|
||||
itemCount,
|
||||
optionsPosition: input.optionsPosition,
|
||||
});
|
||||
|
||||
// A choice belongs to one search. Until this clears it, the query check above ignores it.
|
||||
useEffect(() => {
|
||||
if (!input.isVisible) {
|
||||
previousQueryRef.current = input.query;
|
||||
setSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const queryChanged = previousQueryRef.current !== input.query;
|
||||
previousQueryRef.current = input.query;
|
||||
|
||||
setSelectedIndex((current) => {
|
||||
if (input.options.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const fallbackIndex = getAutocompleteFallbackIndex(
|
||||
input.options.length,
|
||||
input.optionsPosition,
|
||||
);
|
||||
|
||||
if (queryChanged) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
if (current < 0 || current >= input.options.length) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, [input.isVisible, input.options.length, input.query, input.optionsPosition]);
|
||||
setChoice(null);
|
||||
}, [input.isVisible, input.query]);
|
||||
|
||||
const onKeyPress = useCallback(
|
||||
(event: TKeyPressEvent) => {
|
||||
@@ -70,41 +54,22 @@ export function useAutocomplete<
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) =>
|
||||
getAutocompleteNextIndex({
|
||||
currentIndex: current,
|
||||
setChoice({
|
||||
index: getAutocompleteNextIndex({
|
||||
currentIndex: selectedIndex,
|
||||
itemCount: input.options.length,
|
||||
key: "ArrowUp",
|
||||
key: event.key,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) =>
|
||||
getAutocompleteNextIndex({
|
||||
currentIndex: current,
|
||||
itemCount: input.options.length,
|
||||
key: "ArrowDown",
|
||||
}),
|
||||
);
|
||||
query: input.query,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Tab" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const fallbackIndex = getAutocompleteFallbackIndex(
|
||||
input.options.length,
|
||||
input.optionsPosition,
|
||||
);
|
||||
const resolvedIndex =
|
||||
selectedIndex >= 0 && selectedIndex < input.options.length
|
||||
? selectedIndex
|
||||
: fallbackIndex;
|
||||
const selectedOption = input.options[resolvedIndex];
|
||||
const selectedOption = input.options[selectedIndex];
|
||||
if (selectedOption) {
|
||||
input.onSelectOption(selectedOption, event);
|
||||
}
|
||||
@@ -127,3 +92,21 @@ export function useAutocomplete<
|
||||
onKeyPress,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSelectedIndex(input: {
|
||||
choice: { index: number; query: string } | null;
|
||||
query: string;
|
||||
itemCount: number;
|
||||
optionsPosition?: AutocompleteOptionsPosition;
|
||||
}): number {
|
||||
const { choice } = input;
|
||||
if (
|
||||
choice &&
|
||||
choice.query === input.query &&
|
||||
choice.index >= 0 &&
|
||||
choice.index < input.itemCount
|
||||
) {
|
||||
return choice.index;
|
||||
}
|
||||
return getAutocompleteFallbackIndex(input.itemCount, input.optionsPosition);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ interface UseBranchSwitcherInput {
|
||||
|
||||
interface UseBranchSwitcherResult {
|
||||
branchOptions: ComboboxOption[];
|
||||
isLoadingBranches: boolean;
|
||||
isFetchingBranches: boolean;
|
||||
branchError: Error | null;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
handleBranchSelect: (branchId: string) => void;
|
||||
@@ -185,5 +188,14 @@ export function useBranchSwitcher({
|
||||
],
|
||||
);
|
||||
|
||||
return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout };
|
||||
return {
|
||||
branchOptions,
|
||||
isLoadingBranches: branchSuggestionsQuery.isPending,
|
||||
isFetchingBranches: branchSuggestionsQuery.isFetching,
|
||||
branchError: branchSuggestionsQuery.error,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
handleBranchSelect,
|
||||
invalidateStashAndCheckout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -820,7 +820,7 @@ describe("translation resources", () => {
|
||||
expect(en.branchSwitcher.stashRestored).toBe("Stashed changes restored");
|
||||
expect(en.agentAutocomplete.searchingWorkspace).toBe("Searching workspace...");
|
||||
expect(en.agentAutocomplete.noCommands).toBe("No commands found");
|
||||
expect(en.agentAutocomplete.failedToLoad).toBe("Failed to load");
|
||||
expect(en.agentAutocomplete.commandsFailed).toBe("Couldn't load commands");
|
||||
expect(en.loadOlderHistory.failed).toBe("Couldn't load older history");
|
||||
expect(en.agentControls.thinking.extraHigh).toBe("Extra high");
|
||||
expect(en.agentControls.model.unknown).toBe("Unknown model");
|
||||
|
||||
@@ -379,6 +379,24 @@ export const ar: TranslationResources = {
|
||||
answerPlaceholder: "اكتب إجابتك...",
|
||||
otherPlaceholder: "آخر...",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "مهمة واحدة في الخلفية",
|
||||
pillMany: "{{count}} مهمة في الخلفية",
|
||||
notRunning: "لم يعد قيد التشغيل",
|
||||
dismiss: "إغلاق",
|
||||
stop: "إيقاف",
|
||||
stopping: "جارٍ الإيقاف…",
|
||||
stopError: "تعذر تأكيد الإيقاف.",
|
||||
label: "في الخلفية",
|
||||
title: "عمل الخلفية",
|
||||
waiting: "بانتظار تفاصيل عمل الخلفية",
|
||||
error: "تعذر تحميل عمل الخلفية",
|
||||
retry: "إعادة المحاولة",
|
||||
running: "عمل الخلفية قيد التشغيل",
|
||||
shell: "الصدفة",
|
||||
monitor: "المراقبة",
|
||||
other: "عمل آخر",
|
||||
},
|
||||
todo: {
|
||||
title: "المهام",
|
||||
empty: "لا توجد مهام حتى الآن.",
|
||||
@@ -1341,6 +1359,9 @@ export const ar: TranslationResources = {
|
||||
hostDisconnected: "Host غير متصل",
|
||||
hideFailed: "فشل في إخفاء مساحة العمل",
|
||||
archiveFailed: "فشل في أرشفة مساحة العمل",
|
||||
archived: "تمت أرشفة مساحة العمل",
|
||||
undoArchive: "تراجع",
|
||||
undoArchiveFailed: "تعذّر التراجع عن الأرشفة",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1598,6 +1619,7 @@ export const ar: TranslationResources = {
|
||||
openPath: "فتح المسار",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "تعذّر تحميل الفروع. أعد فتح قائمة الفروع للمحاولة مرة أخرى.",
|
||||
triggerTooltip: "تبديل فرع مساحة العمل",
|
||||
currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.",
|
||||
placeholder: "تبديل الفرع...",
|
||||
@@ -1620,7 +1642,11 @@ export const ar: TranslationResources = {
|
||||
loadingCommands: "جارٍ تحميل الأوامر...",
|
||||
noFiles: "لم يتم العثور على ملفات أو أدلة",
|
||||
noCommands: "لم يتم العثور على أي أوامر",
|
||||
failedToLoad: "فشل التحميل",
|
||||
loadingProviderCommands: "جارٍ تحميل أوامر {{provider}}...",
|
||||
commandsFailed: "تعذر تحميل الأوامر",
|
||||
providerCommandsFailed: "تعذر تحميل أوامر {{provider}}",
|
||||
filesFailed: "تعذر البحث في الملفات",
|
||||
skillTag: "مهارة",
|
||||
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
|
||||
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
|
||||
},
|
||||
|
||||
@@ -376,6 +376,24 @@ export const en = {
|
||||
answerPlaceholder: "Type your answer...",
|
||||
otherPlaceholder: "Other...",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "1 background task",
|
||||
pillMany: "{{count}} background tasks",
|
||||
notRunning: "No longer running",
|
||||
dismiss: "Dismiss",
|
||||
stop: "Stop",
|
||||
stopping: "Stopping…",
|
||||
stopError: "Could not confirm stop.",
|
||||
label: "Background",
|
||||
title: "Background work",
|
||||
waiting: "Waiting for background work details",
|
||||
error: "Could not load background work",
|
||||
retry: "Retry",
|
||||
running: "Background work running",
|
||||
shell: "Shell",
|
||||
monitor: "Monitor",
|
||||
other: "Other work",
|
||||
},
|
||||
todo: {
|
||||
title: "Tasks",
|
||||
empty: "No tasks yet.",
|
||||
@@ -1349,6 +1367,9 @@ 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1628,6 +1649,7 @@ export const en = {
|
||||
placeholder: "Switch branch...",
|
||||
searchPlaceholder: "Filter branches...",
|
||||
empty: "No branches found.",
|
||||
failedToLoad: "Could not load branches. Reopen the picker to try again.",
|
||||
title: "Switch branch",
|
||||
uncommittedTitle: "Uncommitted changes",
|
||||
uncommittedMessage: "You have uncommitted changes. Stash them before switching branches?",
|
||||
@@ -1646,7 +1668,11 @@ export const en = {
|
||||
loadingCommands: "Loading commands...",
|
||||
noFiles: "No files or directories found",
|
||||
noCommands: "No commands found",
|
||||
failedToLoad: "Failed to load",
|
||||
loadingProviderCommands: "Loading {{provider}} commands...",
|
||||
commandsFailed: "Couldn't load commands",
|
||||
providerCommandsFailed: "Couldn't load {{provider}} commands",
|
||||
filesFailed: "Couldn't search files",
|
||||
skillTag: "Skill",
|
||||
chooseProjectForCommands: "Choose a project to see commands",
|
||||
chooseModelForCommands: "Select a model to see commands",
|
||||
},
|
||||
|
||||
@@ -384,6 +384,24 @@ export const es: TranslationResources = {
|
||||
answerPlaceholder: "Escribe tu respuesta...",
|
||||
otherPlaceholder: "Otro...",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "1 tarea en segundo plano",
|
||||
pillMany: "{{count}} tareas en segundo plano",
|
||||
notRunning: "Ya no está en ejecución",
|
||||
dismiss: "Cerrar",
|
||||
stop: "Detener",
|
||||
stopping: "Deteniendo…",
|
||||
stopError: "No se pudo confirmar la detención.",
|
||||
label: "Segundo plano",
|
||||
title: "Trabajo en segundo plano",
|
||||
waiting: "Esperando los detalles",
|
||||
error: "No se pudo cargar el trabajo en segundo plano",
|
||||
retry: "Reintentar",
|
||||
running: "Trabajo en segundo plano en curso",
|
||||
shell: "Consola",
|
||||
monitor: "Monitor",
|
||||
other: "Otro trabajo",
|
||||
},
|
||||
todo: {
|
||||
title: "Tareas",
|
||||
empty: "Aún no hay tareas.",
|
||||
@@ -1377,6 +1395,9 @@ 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1644,6 +1665,7 @@ export const es: TranslationResources = {
|
||||
openPath: "Abrir ruta",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "No se pudieron cargar las ramas. Vuelve a abrir el selector para reintentarlo.",
|
||||
triggerTooltip: "Cambiar rama del espacio de trabajo",
|
||||
currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.",
|
||||
placeholder: "Cambiar de rama...",
|
||||
@@ -1667,7 +1689,11 @@ export const es: TranslationResources = {
|
||||
loadingCommands: "Cargando comandos...",
|
||||
noFiles: "No se encontraron archivos ni directorios",
|
||||
noCommands: "No se encontraron comandos",
|
||||
failedToLoad: "No se pudo cargar",
|
||||
loadingProviderCommands: "Cargando comandos de {{provider}}...",
|
||||
commandsFailed: "No se pudieron cargar los comandos",
|
||||
providerCommandsFailed: "No se pudieron cargar los comandos de {{provider}}",
|
||||
filesFailed: "No se pudieron buscar archivos",
|
||||
skillTag: "Skill",
|
||||
chooseProjectForCommands: "Elige un proyecto para ver los comandos",
|
||||
chooseModelForCommands: "Selecciona un modelo para ver los comandos",
|
||||
},
|
||||
|
||||
@@ -384,6 +384,24 @@ export const fr: TranslationResources = {
|
||||
answerPlaceholder: "Saisissez votre réponse…",
|
||||
otherPlaceholder: "Autre…",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "1 tâche en arrière-plan",
|
||||
pillMany: "{{count}} tâches en arrière-plan",
|
||||
notRunning: "N’est plus en cours",
|
||||
dismiss: "Fermer",
|
||||
stop: "Arrêter",
|
||||
stopping: "Arrêt…",
|
||||
stopError: "Impossible de confirmer l’arrêt.",
|
||||
label: "Arrière-plan",
|
||||
title: "Travail en arrière-plan",
|
||||
waiting: "En attente des détails",
|
||||
error: "Impossible de charger le travail en arrière-plan",
|
||||
retry: "Réessayer",
|
||||
running: "Travail en arrière-plan en cours",
|
||||
shell: "Shell",
|
||||
monitor: "Surveillance",
|
||||
other: "Autre travail",
|
||||
},
|
||||
todo: {
|
||||
title: "Tâches",
|
||||
empty: "Aucune tâche pour l’instant.",
|
||||
@@ -1371,6 +1389,9 @@ 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1640,6 +1661,7 @@ export const fr: TranslationResources = {
|
||||
openPath: "Ouvrir le chemin",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "Impossible de charger les branches. Rouvrez le sélecteur pour réessayer.",
|
||||
triggerTooltip: "Changer la branche de l’espace de travail",
|
||||
currentBranch: "Branche actuelle : {{branchName}}. Appuyez pour changer de branche.",
|
||||
placeholder: "Changer de branche…",
|
||||
@@ -1664,7 +1686,11 @@ export const fr: TranslationResources = {
|
||||
loadingCommands: "Chargement des commandes…",
|
||||
noFiles: "Aucun fichier ou répertoire trouvé",
|
||||
noCommands: "Aucune commande trouvée",
|
||||
failedToLoad: "Échec du chargement",
|
||||
loadingProviderCommands: "Chargement des commandes {{provider}}…",
|
||||
commandsFailed: "Impossible de charger les commandes",
|
||||
providerCommandsFailed: "Impossible de charger les commandes {{provider}}",
|
||||
filesFailed: "Impossible de rechercher les fichiers",
|
||||
skillTag: "Skill",
|
||||
chooseProjectForCommands: "Choisissez un projet pour afficher les commandes",
|
||||
chooseModelForCommands: "Sélectionnez un modèle pour afficher les commandes",
|
||||
},
|
||||
|
||||
@@ -384,6 +384,24 @@ export const ja: TranslationResources = {
|
||||
answerPlaceholder: "回答を入力...",
|
||||
otherPlaceholder: "その他...",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "バックグラウンドタスク1件",
|
||||
pillMany: "バックグラウンドタスク{{count}}件",
|
||||
notRunning: "実行は終了しました",
|
||||
dismiss: "閉じる",
|
||||
stop: "停止",
|
||||
stopping: "停止中…",
|
||||
stopError: "停止を確認できませんでした。",
|
||||
label: "バックグラウンド",
|
||||
title: "バックグラウンド作業",
|
||||
waiting: "作業の詳細を待っています",
|
||||
error: "バックグラウンド作業を読み込めませんでした",
|
||||
retry: "再試行",
|
||||
running: "バックグラウンド作業を実行中",
|
||||
shell: "シェル",
|
||||
monitor: "監視",
|
||||
other: "その他の作業",
|
||||
},
|
||||
todo: {
|
||||
title: "タスク",
|
||||
empty: "タスクがまだありません。",
|
||||
@@ -1355,6 +1373,9 @@ export const ja: TranslationResources = {
|
||||
hostDisconnected: "ホストが接続されていません",
|
||||
hideFailed: "ワークスペースの非表示に失敗しました",
|
||||
archiveFailed: "ワークスペースのアーカイブに失敗しました",
|
||||
archived: "ワークスペースをアーカイブしました",
|
||||
undoArchive: "元に戻す",
|
||||
undoArchiveFailed: "アーカイブを元に戻せませんでした",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1615,6 +1636,7 @@ export const ja: TranslationResources = {
|
||||
openPath: "パスを開く",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "ブランチを読み込めませんでした。選択画面を開き直してお試しください。",
|
||||
triggerTooltip: "ワークスペースのブランチを切り替え",
|
||||
currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。",
|
||||
placeholder: "ブランチを切り替え...",
|
||||
@@ -1638,7 +1660,11 @@ export const ja: TranslationResources = {
|
||||
loadingCommands: "コマンドを読み込み中...",
|
||||
noFiles: "ファイルまたはディレクトリが見つかりません",
|
||||
noCommands: "コマンドが見つかりません",
|
||||
failedToLoad: "読み込みに失敗しました",
|
||||
loadingProviderCommands: "{{provider}} のコマンドを読み込み中...",
|
||||
commandsFailed: "コマンドを読み込めませんでした",
|
||||
providerCommandsFailed: "{{provider}} のコマンドを読み込めませんでした",
|
||||
filesFailed: "ファイルを検索できませんでした",
|
||||
skillTag: "スキル",
|
||||
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
|
||||
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
|
||||
},
|
||||
|
||||
@@ -380,6 +380,24 @@ export const ko: TranslationResources = {
|
||||
answerPlaceholder: "답변을 입력하세요...",
|
||||
otherPlaceholder: "기타...",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "백그라운드 작업 1개",
|
||||
pillMany: "백그라운드 작업 {{count}}개",
|
||||
notRunning: "더 이상 실행 중이 아님",
|
||||
dismiss: "닫기",
|
||||
stop: "중지",
|
||||
stopping: "중지 중…",
|
||||
stopError: "중지를 확인하지 못했습니다.",
|
||||
label: "백그라운드",
|
||||
title: "백그라운드 작업",
|
||||
waiting: "작업 세부 정보를 기다리는 중",
|
||||
error: "백그라운드 작업을 불러오지 못했습니다",
|
||||
retry: "다시 시도",
|
||||
running: "백그라운드 작업 실행 중",
|
||||
shell: "셸",
|
||||
monitor: "모니터",
|
||||
other: "기타 작업",
|
||||
},
|
||||
todo: {
|
||||
title: "작업",
|
||||
empty: "아직 작업이 없습니다.",
|
||||
@@ -1348,6 +1366,9 @@ export const ko: TranslationResources = {
|
||||
hostDisconnected: "호스트가 연결되어 있지 않습니다",
|
||||
hideFailed: "워크스페이스를 숨기지 못했습니다",
|
||||
archiveFailed: "워크스페이스를 보관하지 못했습니다.",
|
||||
archived: "워크스페이스를 보관했습니다",
|
||||
undoArchive: "실행 취소",
|
||||
undoArchiveFailed: "보관을 취소하지 못했습니다",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1608,6 +1629,7 @@ export const ko: TranslationResources = {
|
||||
openPath: "경로 열기",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "브랜치를 불러오지 못했습니다. 선택기를 다시 열어 재시도하세요.",
|
||||
triggerTooltip: "워크스페이스 브랜치 전환",
|
||||
currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.",
|
||||
placeholder: "브랜치 전환...",
|
||||
@@ -1631,7 +1653,11 @@ export const ko: TranslationResources = {
|
||||
loadingCommands: "명령 불러오는 중...",
|
||||
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
|
||||
noCommands: "명령을 찾을 수 없습니다",
|
||||
failedToLoad: "불러오지 못했습니다",
|
||||
loadingProviderCommands: "{{provider}} 명령 불러오는 중...",
|
||||
commandsFailed: "명령을 불러오지 못했습니다",
|
||||
providerCommandsFailed: "{{provider}} 명령을 불러오지 못했습니다",
|
||||
filesFailed: "파일을 검색하지 못했습니다",
|
||||
skillTag: "스킬",
|
||||
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
|
||||
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
|
||||
},
|
||||
|
||||
@@ -383,6 +383,24 @@ export const ptBR: TranslationResources = {
|
||||
answerPlaceholder: "Digite sua resposta...",
|
||||
otherPlaceholder: "Outro...",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "1 tarefa em segundo plano",
|
||||
pillMany: "{{count}} tarefas em segundo plano",
|
||||
notRunning: "Não está mais em execução",
|
||||
dismiss: "Dispensar",
|
||||
stop: "Parar",
|
||||
stopping: "Parando…",
|
||||
stopError: "Não foi possível confirmar a parada.",
|
||||
label: "Segundo plano",
|
||||
title: "Trabalho em segundo plano",
|
||||
waiting: "Aguardando os detalhes",
|
||||
error: "Não foi possível carregar o trabalho em segundo plano",
|
||||
retry: "Tentar novamente",
|
||||
running: "Trabalho em segundo plano em andamento",
|
||||
shell: "Shell",
|
||||
monitor: "Monitor",
|
||||
other: "Outro trabalho",
|
||||
},
|
||||
todo: {
|
||||
title: "Tarefas",
|
||||
empty: "Nenhuma tarefa ainda.",
|
||||
@@ -1367,6 +1385,9 @@ 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1629,6 +1650,7 @@ export const ptBR: TranslationResources = {
|
||||
openPath: "Abrir caminho",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "Não foi possível carregar as branches. Reabra o seletor para tentar novamente.",
|
||||
triggerTooltip: "Trocar branch do workspace",
|
||||
currentBranch: "Branch atual: {{branchName}}. Pressione para trocar de branch.",
|
||||
placeholder: "Trocar branch...",
|
||||
@@ -1652,7 +1674,11 @@ export const ptBR: TranslationResources = {
|
||||
loadingCommands: "Carregando comandos...",
|
||||
noFiles: "Nenhum arquivo ou diretório encontrado",
|
||||
noCommands: "Nenhum comando encontrado",
|
||||
failedToLoad: "Falha ao carregar",
|
||||
loadingProviderCommands: "Carregando comandos do {{provider}}...",
|
||||
commandsFailed: "Não foi possível carregar os comandos",
|
||||
providerCommandsFailed: "Não foi possível carregar os comandos do {{provider}}",
|
||||
filesFailed: "Não foi possível buscar arquivos",
|
||||
skillTag: "Skill",
|
||||
chooseProjectForCommands: "Escolha um projeto para ver os comandos",
|
||||
chooseModelForCommands: "Selecione um modelo para ver os comandos",
|
||||
},
|
||||
|
||||
@@ -383,6 +383,24 @@ export const ru: TranslationResources = {
|
||||
answerPlaceholder: "Введите ответ...",
|
||||
otherPlaceholder: "Другой ответ...",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "1 фоновая задача",
|
||||
pillMany: "Фоновые задачи: {{count}}",
|
||||
notRunning: "Больше не выполняется",
|
||||
dismiss: "Закрыть",
|
||||
stop: "Остановить",
|
||||
stopping: "Остановка…",
|
||||
stopError: "Не удалось подтвердить остановку.",
|
||||
label: "В фоне",
|
||||
title: "Фоновые задачи",
|
||||
waiting: "Ожидание сведений о задачах",
|
||||
error: "Не удалось загрузить фоновые задачи",
|
||||
retry: "Повторить",
|
||||
running: "Фоновые задачи выполняются",
|
||||
shell: "Оболочка",
|
||||
monitor: "Монитор",
|
||||
other: "Другая задача",
|
||||
},
|
||||
todo: {
|
||||
title: "Задачи",
|
||||
empty: "Задач пока нет.",
|
||||
@@ -1359,6 +1377,9 @@ export const ru: TranslationResources = {
|
||||
hostDisconnected: "Хост не подключён",
|
||||
hideFailed: "Не удалось скрыть рабочее пространство",
|
||||
archiveFailed: "Не удалось архивировать рабочее пространство",
|
||||
archived: "Рабочее пространство архивировано",
|
||||
undoArchive: "Отменить",
|
||||
undoArchiveFailed: "Не удалось отменить архивацию",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1626,6 +1647,7 @@ export const ru: TranslationResources = {
|
||||
openPath: "Открыть путь",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "Не удалось загрузить ветки. Откройте список заново, чтобы повторить попытку.",
|
||||
triggerTooltip: "Переключить ветку рабочего пространства",
|
||||
currentBranch: "Текущая ветка: {{branchName}}. Нажмите, чтобы переключить ветку.",
|
||||
placeholder: "Сменить ветку...",
|
||||
@@ -1650,7 +1672,11 @@ export const ru: TranslationResources = {
|
||||
loadingCommands: "Загрузка команд...",
|
||||
noFiles: "Файлы и каталоги не найдены",
|
||||
noCommands: "Команды не найдены",
|
||||
failedToLoad: "Не удалось загрузить",
|
||||
loadingProviderCommands: "Загрузка команд {{provider}}...",
|
||||
commandsFailed: "Не удалось загрузить команды",
|
||||
providerCommandsFailed: "Не удалось загрузить команды {{provider}}",
|
||||
filesFailed: "Не удалось найти файлы",
|
||||
skillTag: "Навык",
|
||||
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
|
||||
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
|
||||
},
|
||||
|
||||
@@ -379,6 +379,24 @@ export const zhCN: TranslationResources = {
|
||||
answerPlaceholder: "输入你的回答...",
|
||||
otherPlaceholder: "其他...",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "1 个后台任务",
|
||||
pillMany: "{{count}} 个后台任务",
|
||||
notRunning: "已不再运行",
|
||||
dismiss: "关闭",
|
||||
stop: "停止",
|
||||
stopping: "正在停止…",
|
||||
stopError: "无法确认已停止。",
|
||||
label: "后台",
|
||||
title: "后台任务",
|
||||
waiting: "正在等待后台任务详情",
|
||||
error: "无法加载后台任务",
|
||||
retry: "重试",
|
||||
running: "后台任务运行中",
|
||||
shell: "Shell",
|
||||
monitor: "监控",
|
||||
other: "其他任务",
|
||||
},
|
||||
todo: {
|
||||
title: "任务",
|
||||
empty: "还没有任务。",
|
||||
@@ -1330,6 +1348,9 @@ export const zhCN: TranslationResources = {
|
||||
hostDisconnected: "Host 未连接",
|
||||
hideFailed: "隐藏 workspace 失败",
|
||||
archiveFailed: "归档工作区失败",
|
||||
archived: "工作区已归档",
|
||||
undoArchive: "撤销",
|
||||
undoArchiveFailed: "无法撤销归档",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1581,6 +1602,7 @@ export const zhCN: TranslationResources = {
|
||||
openPath: "打开路径",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "无法加载分支,请重新打开选择器以重试。",
|
||||
triggerTooltip: "切换工作区分支",
|
||||
currentBranch: "当前分支:{{branchName}}。按下以切换分支。",
|
||||
placeholder: "切换分支...",
|
||||
@@ -1603,7 +1625,11 @@ export const zhCN: TranslationResources = {
|
||||
loadingCommands: "正在加载 commands...",
|
||||
noFiles: "没有找到文件或目录",
|
||||
noCommands: "没有找到 commands",
|
||||
failedToLoad: "加载失败",
|
||||
loadingProviderCommands: "正在加载 {{provider}} commands...",
|
||||
commandsFailed: "无法加载 commands",
|
||||
providerCommandsFailed: "无法加载 {{provider}} commands",
|
||||
filesFailed: "无法搜索文件",
|
||||
skillTag: "Skill",
|
||||
chooseProjectForCommands: "选择项目以查看命令",
|
||||
chooseModelForCommands: "选择模型以查看命令",
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@ export type KeyboardActionId =
|
||||
| "workspace.project.pick"
|
||||
| "worktree.new"
|
||||
| "workspace.archive"
|
||||
| "workspace.archive.undo"
|
||||
| "workspace.pin"
|
||||
| "view.toggle.focus"
|
||||
| "theme.cycle"
|
||||
|
||||
@@ -51,6 +51,7 @@ 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"
|
||||
@@ -109,6 +110,7 @@ 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,6 +408,25 @@ 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,6 +169,7 @@ export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[
|
||||
"workspace-next",
|
||||
"pin-workspace",
|
||||
"archive-workspace",
|
||||
"undo-archive-workspace",
|
||||
],
|
||||
"tabs-panes": [
|
||||
"workspace-tab-new",
|
||||
@@ -361,6 +362,31 @@ 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,6 +48,7 @@ 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" },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user