Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andlikaixuan cd845129e9 test(app): reproduce lost floating panel hosts after overlap
Preserve a failing reproduction with real portal hosts. Removing the newest host must leave an older mounted host measurable.

Co-authored-by: likaixuan <likaixuan@mucang.cn>
2026-11-06 16:00:30 +00:00
Mohamed BoudraandClaude 161076a853 Show loading and errors in the slash command menu, and stop defaulting to /exit (#8905)
* feat(app): show loading and errors in the composer command menu

Rows are single lines with a kind icon, the matched part of the name, the
argument hint, the description and a source tag. The detail card is gone.

The menu used to stay hidden while commands loaded and then show
"Error: ..." over a list whose options Enter could still pick, so a failed
load plus Enter archived the agent through a hidden /exit. Loading and
error are now status rows with no options; the error row names the reason
and offers Retry. Loading appears after 300ms so a warm cache paints the
results first, as before.

An empty query used to select /exit. Provider commands now come first, so
the bottom row the input selects is a provider command.

useAutocomplete derives the selection during render. When results replace
the loading row, the selection is valid in the same render, so the popover
stays mounted at its anchor instead of remounting.

Claude Code's `__`-prefixed commands (/__remote-workflow) only run in its
own server-launched sessions, so the Claude provider no longer lists them.

* fix(app): never default the command menu to a Paseo action

An arrow-key choice was tied to the query it was made for, so returning to
that query brought it back: arrow to /exit on `/`, type `com`, delete it, and
Enter archived the agent. A choice now clears whenever the query changes.

An empty query still selected /exit when the agent had no provider or plugin
commands, because Paseo's actions were the only entries. With nothing else to
list, an empty query now lists nothing and waits for a search; typing `/ex`
still finds /exit.

* fix(app): sort empty-query commands without toSorted

Hermes has no Array.prototype.toSorted, so typing `/` threw "undefined is
not a function" on iOS and Android and the app showed its error screen.
Browsers and Node have it, which is why web and unit tests passed.

* test(app): cover the stale command choice in Playwright and wait out the loading row on devices

The renderHook test mounted React under JSDOM, which docs/testing.md forbids.
The Playwright test drives the same sequence in a real browser and fails
without the reset in useAutocomplete: Enter chose the stale /exit.

The composer-keyboard harnesses treated a visible popover as a ready list.
The popover now shows a loading row first, and a cold Claude command list
takes several seconds, so both harnesses wait for the rows.

* fix(app): size the command menu loading spinner to the row icons

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-06 22:15:10 +07:00
paseo-bot[bot]anda-sh 14845d3ff3 Keep ACP mode selections valid after config updates (#8912)
* fix(acp): preserve session mode identity across config updates

* test(acp): verify mode identity through public sessions

Exercise initialization, queued notifications, mode selection, and rejection over stdio instead of replacing private connection state.

Co-authored-by: a-sh <846704+a-sh@users.noreply.github.com>

---------

Co-authored-by: a-sh <846704+a-sh@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 15:10:43 +00:00
Mohamed Boudra 616f5e155c fix(app): show branch loading and fetch errors (#8938)
* fix(app): distinguish branch loading from empty results

Preserve branch query pending and error states through the switcher so first open does not report missing branches before the response. Show request failures with a retry action.

* fix(app): keep branch retry progress and keyboard focus

Verify failed cached refresh and keyboard retry through the real browser and daemon. Keep focus in the footer while Retry is disabled so repeated Enter cannot select a cached branch.

* fix(app): preserve picker interaction when retrying branch loads

Use the existing close-and-reopen retry behavior with clear feedback rather than introducing an action that competes with native list keyboard ownership.
2026-11-06 21:35:20 +07:00
Mohamed Boudra 471db4bdb5 Keep workspace tabs predictable and clear attention on deliberate focus (#8935)
* fix(app): preserve workspace tabs and acknowledge deliberate agent focus

Workspace navigation no longer chooses an attention agent. Preserve repeated focus actions so an already selected chat can be acknowledged, and stop consuming attention on departure.

* fix(app): retain deliberate focus until fresh agent data arrives

Connection readiness permits a pending focus acknowledgement without creating a new focus action. Keep that intent across client changes and wait for the current timeline synchronization generation before consuming it.

* fix(app): preserve reconnect feedback during attention acknowledgement
2026-11-06 20:55:16 +07:00
Mohamed Boudra 68fc16b855 fix(terminal): run terminal workers without process identity queries (#8818)
* fix(terminal): run terminals when process identity cannot be read

On some Windows machines Windows PowerShell cannot run Get-CimInstance, or
cannot be started at all. The terminal worker refused to start without its own
birth time and command line, so those machines had no terminals.

Process identity only authorizes signals. The worker now starts anyway and
records its identity as unavailable, and programs without a birth time. Cleanup
never signals what it cannot identify. It asks such a worker to shut down over
the socket the secret authenticates, and reports survivors as unconfirmed.

So records of crashed workers do not block launches, the process table falls
back to process.kill(pid, 0) when the query fails. When a live pid cannot be
identified, a refused connection on the worker's socket marks it as another
process, because a worker listens until it exits.

* fix(terminal): ask unidentified workers to shut down on every cleanup

Retries of an unconfirmed cleanup and startup cleanup run without a grace
period, which skipped the shutdown request. For a worker whose identity is
unavailable that request is the only way to stop it, so it is now always sent.

A gone program recorded without a birth time is no longer walked through the
process table: the table was unreadable when it started, and the walk only kept
the cleanup unconfirmed.

Also test the socket probe on every platform, restore a test of the daemon
starting when the worker cannot, and keep one pid liveness helper.

* test(terminal): fail the worker launch the same way on every platform

Windows opens a directory sitting at the launcher's log path, so the
worker started there and the #8810 test never saw a failed launch. An
invalid protocol override makes the worker exit before it writes its
record on every platform.

* fix(terminal): identify workers through their recorded sockets

* fix(terminal): authenticate legacy sockets before forced cleanup

* test(terminal): exercise closing records without process queries

* test(terminal): identify stale lifecycle records by their sockets

* refactor(terminal): drop the unreleased unavailable-identity record shape

No release wrote identity: { unavailable }; it existed only between
commits of this change. Records keep reading the identity workers up to
0.15.1 wrote, and nothing consults it.
2026-11-06 20:20:18 +07:00
Mohamed Boudra ae157dbfb6 Even out chat footer spacing and show the Changes base only on worktrees (#8934)
* fix(app): space the turn footer like the rest of the timeline

The footer sat 13px under the last line of text while the text sat about
21px under a tool or thinking row, so the footer looked glued to the
message. The message drops its bottom padding when a footer follows, so the
footer's top margin alone sets that gap; 20 makes the centre-to-centre
distance match the tool-to-text gap.

* fix(app): show the Changes base only on Paseo worktrees

The header rendered "branch -> base" whenever checkout status carried a
baseRef, and the daemon sends one for every git checkout, so plain
checkouts showed "-> main" with no way to change it. Only Paseo worktrees
store a base, so only they show it. The arrow is extra muted so it reads as
a separator, and the base no longer shrinks, so a long branch name truncates
instead of the base.

* fix(app): let a long Changes base truncate instead of overflowing
2026-11-06 20:20:11 +07:00
geoqiao dc51a3b6a9 fix(app): preserve plugin-rendered replies when collapsing turns (#8811)
* fix(app): preserve plugin-rendered replies when collapsing turns

* test(app): exercise plugin reply folding in the browser
2026-11-06 13:12:44 +00:00
77798b2e46 Show and stop agent background work (#6220)
* feat(server): add an in-memory store for agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): report background tasks as background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(server): track agent background work in the agent manager

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(protocol): add agent background work messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(server): serve and push agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(client): expose agent background work on agent handles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sdk): document agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): cover background shells in background work end to end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): resync background work subscriptions on every snapshot

subscribe() now lists the agent's background work each time the event
observation is established, so the listener starts with the current list
and catches up after a reconnect. A list answered after a newer update
is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): answer background work lists without loading dormant agents

Background work lives only in memory, so a stored agent that is not
loaded has none. The list handler now answers [] from storage instead of
resuming the agent, and still rejects unknown, internal, and archived
agents. A new test pins that old apps never receive
agent.background_work.update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): read Claude background tasks defensively

The user's claude binary can differ from the SDK typings, so a task's
description is read as unknown and an empty or missing one becomes null.
Drop the monitor kind: Claude Monitor watches report as local_bash, so
kind is "shell" or "other" today. The store now copies only the wire
fields of each input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(server): prove internal background work was emitted before checking globals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sdk): describe background work resync and feature gating

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): cover background work reported by a retired query

The daemon stops reading a Claude query it no longer owns, so work that a
retired process still had queued cannot reach the background work list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): let background work subscribers handle a failed list read

When the list read that follows each subscription snapshot failed, the
error was only logged and the caller got neither the list nor a failure.
subscribe() now takes an optional onError that receives it; without one
the error is still logged. The read is not retried, since the next
reconnect reads the list again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(agents): show background work as active

Keep background activity visible after a foreground turn ends while preserving independent send and interrupt behavior.

* fix(agents): protect background work subscriptions

Keep internal work out of public updates and restore observation after a subscription failure.

* feat(agents): show Codex and OpenCode background work

Keep provider-owned shell activity visible after a turn ends while leaving foreground turn controls independent.

* feat(agents): stop individual background work

Wait for provider confirmation so stopping one background item leaves sibling work and foreground turn controls independent.

* refactor(agents): name background stop parameters

* test(website): isolate browser tests from live metadata

* test(agents): clarify background stop journeys

* fix(sdk): report background work subscription failures

Report setup failures so subscribers can replace an observation that the host released.

* fix(app): restore background work after reconnect

Wait for the host handshake before selecting the retained client so an offline pane starts observing when the connection returns.

* fix(agents): retain background shells across rewind

Preserve known live shell ownership when conversation history is rewound, and revoke it when the provider runtime exits.

* fix(website): launch test seeder across platforms

Invoke Wrangler through Node so local website test startup does not depend on an executable npm command shim.

* feat(agents): clarify background task details and tracks

Keep launch descriptions and commands distinct so background work remains identifiable, and allow composer tracks to scroll when space is limited.

* fix(agents): preserve active background work during archive

Keep background-running children out of bulk cleanup and warn before an individual archive. Preserve first taps on composer tracks while the keyboard is open.

* fix(codex): retain background terminals across rewind

Keep live terminal inventories attached to their originating threads so a fork does not hide or misroute their stop actions.

* fix(codex): scope background terminal identities to threads

Prevent commands on separate threads with the same provider process ID from sharing a stop target.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-11-06 19:49:49 +07:00
159 changed files with 8302 additions and 1348 deletions
+6 -3
View File
@@ -334,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
+1 -1
View File
@@ -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.
+8 -5
View File
@@ -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,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");
});
+69 -6
View File
@@ -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 });
@@ -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) {
@@ -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,
@@ -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,
};
}
+19 -9
View File
@@ -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();
@@ -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",
@@ -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();
});
+115
View File
@@ -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?.();
};
},
});
}
+155
View File
@@ -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();
});
+121
View File
@@ -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,
@@ -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>
+197 -180
View File
@@ -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>;
@@ -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();
}
});
});
+1 -4
View File
@@ -1058,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>
+24 -4
View File
@@ -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],
+2 -3
View File
@@ -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,
};
}
+254 -103
View File
@@ -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,
+41 -58
View File
@@ -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);
}
+13 -1
View File
@@ -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,
};
}
+1 -1
View File
@@ -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");
+24 -1
View File
@@ -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: "لا توجد مهام حتى الآن.",
@@ -1601,6 +1619,7 @@ export const ar: TranslationResources = {
openPath: "فتح المسار",
},
branchSwitcher: {
failedToLoad: "تعذّر تحميل الفروع. أعد فتح قائمة الفروع للمحاولة مرة أخرى.",
triggerTooltip: "تبديل فرع مساحة العمل",
currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.",
placeholder: "تبديل الفرع...",
@@ -1623,7 +1642,11 @@ export const ar: TranslationResources = {
loadingCommands: "جارٍ تحميل الأوامر...",
noFiles: "لم يتم العثور على ملفات أو أدلة",
noCommands: "لم يتم العثور على أي أوامر",
failedToLoad: "فشل التحميل",
loadingProviderCommands: "جارٍ تحميل أوامر {{provider}}...",
commandsFailed: "تعذر تحميل الأوامر",
providerCommandsFailed: "تعذر تحميل أوامر {{provider}}",
filesFailed: "تعذر البحث في الملفات",
skillTag: "مهارة",
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
},
+24 -1
View File
@@ -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.",
@@ -1631,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?",
@@ -1649,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",
},
+24 -1
View File
@@ -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.",
@@ -1647,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...",
@@ -1670,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",
},
+24 -1
View File
@@ -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.",
@@ -1643,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…",
@@ -1667,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",
},
+24 -1
View File
@@ -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: "タスクがまだありません。",
@@ -1618,6 +1636,7 @@ export const ja: TranslationResources = {
openPath: "パスを開く",
},
branchSwitcher: {
failedToLoad: "ブランチを読み込めませんでした。選択画面を開き直してお試しください。",
triggerTooltip: "ワークスペースのブランチを切り替え",
currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。",
placeholder: "ブランチを切り替え...",
@@ -1641,7 +1660,11 @@ export const ja: TranslationResources = {
loadingCommands: "コマンドを読み込み中...",
noFiles: "ファイルまたはディレクトリが見つかりません",
noCommands: "コマンドが見つかりません",
failedToLoad: "読み込みに失敗しました",
loadingProviderCommands: "{{provider}} のコマンドを読み込み中...",
commandsFailed: "コマンドを読み込めませんでした",
providerCommandsFailed: "{{provider}} のコマンドを読み込めませんでした",
filesFailed: "ファイルを検索できませんでした",
skillTag: "スキル",
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
},
+24 -1
View File
@@ -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: "아직 작업이 없습니다.",
@@ -1611,6 +1629,7 @@ export const ko: TranslationResources = {
openPath: "경로 열기",
},
branchSwitcher: {
failedToLoad: "브랜치를 불러오지 못했습니다. 선택기를 다시 열어 재시도하세요.",
triggerTooltip: "워크스페이스 브랜치 전환",
currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.",
placeholder: "브랜치 전환...",
@@ -1634,7 +1653,11 @@ export const ko: TranslationResources = {
loadingCommands: "명령 불러오는 중...",
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
noCommands: "명령을 찾을 수 없습니다",
failedToLoad: "불러오지 못했습니다",
loadingProviderCommands: "{{provider}} 명령 불러오는 중...",
commandsFailed: "명령을 불러오지 못했습니다",
providerCommandsFailed: "{{provider}} 명령을 불러오지 못했습니다",
filesFailed: "파일을 검색하지 못했습니다",
skillTag: "스킬",
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
},
+24 -1
View File
@@ -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.",
@@ -1632,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...",
@@ -1655,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",
},
+24 -1
View File
@@ -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: "Задач пока нет.",
@@ -1629,6 +1647,7 @@ export const ru: TranslationResources = {
openPath: "Открыть путь",
},
branchSwitcher: {
failedToLoad: "Не удалось загрузить ветки. Откройте список заново, чтобы повторить попытку.",
triggerTooltip: "Переключить ветку рабочего пространства",
currentBranch: "Текущая ветка: {{branchName}}. Нажмите, чтобы переключить ветку.",
placeholder: "Сменить ветку...",
@@ -1653,7 +1672,11 @@ export const ru: TranslationResources = {
loadingCommands: "Загрузка команд...",
noFiles: "Файлы и каталоги не найдены",
noCommands: "Команды не найдены",
failedToLoad: "Не удалось загрузить",
loadingProviderCommands: "Загрузка команд {{provider}}...",
commandsFailed: "Не удалось загрузить команды",
providerCommandsFailed: "Не удалось загрузить команды {{provider}}",
filesFailed: "Не удалось найти файлы",
skillTag: "Навык",
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
},
+24 -1
View File
@@ -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: "还没有任务。",
@@ -1584,6 +1602,7 @@ export const zhCN: TranslationResources = {
openPath: "打开路径",
},
branchSwitcher: {
failedToLoad: "无法加载分支,请重新打开选择器以重试。",
triggerTooltip: "切换工作区分支",
currentBranch: "当前分支:{{branchName}}。按下以切换分支。",
placeholder: "切换分支...",
@@ -1606,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: "选择模型以查看命令",
},
@@ -11,6 +11,18 @@ import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model
import { useMobilePanelsRuntime } from "./provider";
import { resolveMobilePanelGestureIntent } from "./gesture-intent";
/** A horizontal surface takes the current touch before either panel opens. */
export function usePanelAwareHorizontalScrollGesture() {
const { leftOpenGestureRef, rightOpenGestureRef } = useMobilePanelsRuntime();
return useMemo(
() =>
Gesture.Native()
.blocksExternalGesture(leftOpenGestureRef, rightOpenGestureRef)
.disallowInterruption(true),
[leftOpenGestureRef, rightOpenGestureRef],
);
}
function isCurrentSelection(startedRevision: number): boolean {
return usePanelStore.getState().mobilePanel.revision === startedRevision;
}
@@ -105,6 +105,29 @@ describe("shared agent descriptor", () => {
expect(describeAgent({ isTurnActive: true }).statusBucket).toBe("running");
});
it("shows background work as running without making the turn active", () => {
expect(describeAgent({ backgroundWork: { count: 1 } })).toMatchObject({
statusBucket: "running",
subtitle: "Background work running",
});
expect(
describeAgent({
backgroundWork: { count: 1 },
attentionReason: "error",
requiresAttention: true,
}).statusBucket,
).toBe("failed");
expect(describeAgent({ backgroundWork: { count: 1 }, status: "error" }).statusBucket).toBe(
"running",
);
});
it("localizes the background work detail", async () => {
await i18n.changeLanguage("zh-CN");
expect(describeAgent({ backgroundWork: { count: 1 } }).subtitle).toBe("后台任务运行中");
await i18n.changeLanguage("en");
});
it("surfaces pending permissions even while the turn is running", () => {
expect(describeAgent({ isTurnActive: true, pendingPermissionCount: 1 }).statusBucket).toBe(
"needs_input",
+23 -22
View File
@@ -677,8 +677,6 @@ function ChatAgentContent({
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null);
const clearOnAgentBlurRef = useRef<() => void>(() => {});
const wasPaneFocusedRef = useRef(isPaneFocused);
const initAttemptTokenRef = useRef(0);
const routeBottomAnchorRequestRef = useRef<{
routeKey: string;
@@ -753,35 +751,31 @@ function ChatAgentContent({
const hasHydratedHistoryBefore =
hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted";
const focusRequest = useWorkspaceLayoutStore((state) => {
const request = state.focusRequest;
return request?.workspaceKey === `${serverId}:${workspaceId}` && request.tabId === tabId
? request
: null;
});
const reportAttentionError = useCallback(
(error: unknown) => {
toastApi.show(String(error), { variant: "error", durationMs: null });
},
[toastApi],
);
const attentionController = useAgentAttentionClear({
agentId,
client,
isConnected,
isAgentDataReady: agentHistorySyncGeneration === historySyncGeneration,
requiresAttention: agentState.requiresAttention,
attentionReason: agentState.attentionReason,
isScreenFocused: isPaneFocused,
focusRequest,
onError: reportAttentionError,
});
useEffect(() => {
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
}, [attentionController.clearOnAgentBlur]);
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
useEffect(() => {
if (wasPaneFocusedRef.current && !isPaneFocused) {
clearOnAgentBlurRef.current();
}
wasPaneFocusedRef.current = isPaneFocused;
}, [isPaneFocused]);
useEffect(() => {
return () => {
if (wasPaneFocusedRef.current) {
clearOnAgentBlurRef.current();
}
};
}, []);
const isInitializing = agentId ? isInitializingFromMap : false;
const isHistorySyncing = useMemo(() => {
if (!agentId || !isInitializing) {
@@ -1185,8 +1179,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
) : (
agentComposerSection
);
const handleAttentionInteraction = useCallback(() => {
onAttentionInputFocus();
return false;
}, [onAttentionInputFocus]);
const streamContent = (
<View style={animatedStaticStyles.content}>
<View
style={animatedStaticStyles.content}
onStartShouldSetResponderCapture={handleAttentionInteraction}
>
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
<RenderProfile id={`AgentStreamSection:${agentId}`}>
<AgentStreamSection
+12 -1
View File
@@ -1,5 +1,6 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { memo, useCallback, type ReactElement } from "react";
import { memo, useCallback, useMemo, type ReactElement } from "react";
import { BackgroundWorkTrack, createHostBackgroundWorkModel } from "@/background-work";
import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
import { AgentTaskList } from "@/composer/task-list";
import { ComposerTrackBar } from "@/composer/tracks";
@@ -51,6 +52,15 @@ export const AgentTracks = memo(function AgentTracks({
rows: subagentRows,
});
const { tabId } = usePaneContext();
const backgroundWorkCount = useSessionStore((state) => {
const session = state.sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
return agent?.backgroundWork?.count ?? 0;
});
const backgroundModel = useMemo(
() => createHostBackgroundWorkModel(serverId, agentId),
[serverId, agentId],
);
const isCompact = useIsCompactFormFactor();
const openInSidePane = useSettings((settings) => settings.openInSidePane);
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
@@ -111,6 +121,7 @@ export const AgentTracks = memo(function AgentTracks({
return (
<ComposerTrackBar>
<BackgroundWorkTrack model={backgroundModel} count={backgroundWorkCount} />
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
<AgentTaskList tasks={tasks} />
<SubagentsTrack
@@ -98,6 +98,8 @@ describe("plugin timeline projection", () => {
{
kind: "plugin",
id: "inline-thinking/thought-1/0",
sourceId: "thought-1",
sourceKind: "thought",
data: { text: "First", phase: "streaming" },
},
]);
@@ -106,6 +106,7 @@ function transformSourceItem(
kind: "plugin",
id: `${pluginItem.pluginId}/${pluginItem.id}`,
sourceId: item.id,
sourceKind: item.kind,
timestamp: item.timestamp,
pluginId: pluginItem.pluginId,
pluginItemId: pluginItem.id,
@@ -232,6 +232,7 @@ const StoredAgentSnapshotSchema = z.strictObject({
updatedAt: IsoDateSchema,
lastUserMessageAt: IsoDateSchema.nullable(),
status: AgentStatusSchema,
backgroundWork: z.object({ count: z.number().int().nonnegative() }).optional(),
activeTurn: z
.strictObject({
turnId: z.string(),
@@ -670,6 +671,7 @@ function serializeAgent(agent: Agent): StoredAgent {
labels: agent.labels,
...(agent.background ? { background: true } : {}),
requiresAttention: agent.requiresAttention ?? false,
backgroundWork: agent.backgroundWork,
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
archivedAt: agent.archivedAt?.toISOString() ?? null,
@@ -380,9 +380,9 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const handleTouch = useCallback(() => {
if (!isPaneFocused && paneId) onFocusPane(paneId);
if (paneId) onFocusPane(paneId);
return false;
}, [isPaneFocused, onFocusPane, paneId]);
}, [onFocusPane, paneId]);
return (
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
@@ -37,12 +37,11 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
function navigateDeps(): NavigateToWorkspaceDeps {
return {
revealTab: revealWorkspaceTab,
revealWorkspace: () => {
revealWorkspace: (workspaceKey) => {
useWorkspaceLayoutStore.getState().focusWorkspace(workspaceKey);
usePanelStore.getState().showMobileAgent();
},
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
getSessionAgents: (serverId) =>
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(),
deferOpenTab: (input) => useWorkspaceLayoutStore.getState().deferOpenTab(input),
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
@@ -9,7 +9,7 @@ import {
type NavigateToLastWorkspaceDeps,
type NavigateToWorkspaceDeps,
} from "./navigation";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
interface RecordedTab {
workspaceKey: string;
@@ -35,7 +35,6 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
return null;
},
getSessionWorkspaces: () => null,
getSessionAgents: () => [] as Agent[],
isWorkspaceLayoutHydrated: () => true,
openTab: ({ workspaceKey, target, pin = false }) => {
openedTabs.push({ workspaceKey, target, pin });
@@ -105,49 +104,27 @@ describe("workspace navigation", () => {
expect(remembered).toEqual([{ serverId: "server-1", workspaceId: "workspace-a" }]);
});
it("focuses the attention agent's tab when a workspace has one", () => {
it("reveals the workspace without choosing a different tab", () => {
const workspace = {
id: "workspace-a",
workspaceDirectory: "/repo/workspace-a",
} as WorkspaceDescriptor;
const agent = {
id: "agent-1",
cwd: "/repo/workspace-a",
workspaceId: "workspace-a",
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;
const { deps, openedTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
getSessionAgents: () => [agent],
});
navigateToWorkspace({ serverId: "server-1", workspaceId: "workspace-a" }, deps);
expect(openedTabs).toEqual([
{
workspaceKey: "server-1:workspace-a",
target: { kind: "agent", agentId: "agent-1" },
pin: false,
},
]);
expect(openedTabs).toEqual([]);
});
it("keeps an explicit tab authoritative over an attention agent", () => {
it("opens an explicitly requested tab", () => {
const workspace = {
id: "workspace-a",
workspaceDirectory: "/repo/workspace-a",
} as WorkspaceDescriptor;
const agent = {
id: "agent-1",
cwd: "/repo/workspace-a",
workspaceId: "workspace-a",
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;
const { deps, openedTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
getSessionAgents: () => [agent],
});
navigateToWorkspace(
@@ -1,14 +1,10 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { pickAttentionAgent } from "@/utils/agent-attention";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
normalizeWorkspaceOpaqueId,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
@@ -33,13 +29,12 @@ export interface NavigateToWorkspaceInput {
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
getSessionAgents: (serverId: string) => Iterable<Agent>;
isWorkspaceLayoutHydrated: () => boolean;
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
navigateToRoute: (route: string) => void;
revealTab: (workspaceKey: string, tabId: string | null) => void;
revealWorkspace: () => void;
revealWorkspace: (workspaceKey: string) => void;
}
export interface NavigateToLastWorkspaceDeps extends NavigateToWorkspaceDeps {
@@ -104,21 +99,7 @@ export function navigateToWorkspace(
if (input.target) {
prepareWorkspaceTab({ ...input, target: input.target }, { openTab });
} else {
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(input.serverId)).filter(
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
openTab({
workspaceKey: `${input.serverId}:${resolvedWorkspaceId}`,
target: { kind: "agent", agentId: attentionAgentId },
intent: "reveal",
});
} else {
deps.revealWorkspace();
}
deps.revealWorkspace(`${input.serverId}:${resolvedWorkspaceId ?? input.workspaceId}`);
}
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
+1
View File
@@ -74,6 +74,7 @@ export interface Agent {
id: string;
provider: AgentProvider;
status: AgentLifecycleStatus;
backgroundWork?: { count: number };
turn: TurnLiveness;
createdAt: Date;
updatedAt: Date;
@@ -5244,3 +5244,36 @@ describe("pending workspace opens", () => {
expect(store.getState().pendingOpenByWorkspace).toEqual({});
});
});
it("records repeated deliberate focus even when the selected pane and tab do not change", () => {
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
const workspaceKey = "server:focus";
const tabId = store
.getState()
.openTab({ workspaceKey, target: { kind: "agent", agentId: "agent" }, intent: "reveal" })!;
const paneId = store.getState().layoutByWorkspace[workspaceKey]!.focusedPaneId!;
for (const focus of [
() => store.getState().focusPane(workspaceKey, paneId),
() => store.getState().focusTab(workspaceKey, tabId),
() => store.getState().selectTabInPane(workspaceKey, paneId, tabId),
]) {
focus();
const first = store.getState().focusRequest;
focus();
expect(store.getState().focusRequest).toEqual({ workspaceKey, tabId });
expect(store.getState().focusRequest).not.toBe(first);
}
});
it("reentering a workspace restores its suspended pane without choosing another tab", () => {
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
const workspaceKey = "server:resume";
const tabId = store
.getState()
.openTab({ workspaceKey, target: { kind: "agent", agentId: "agent" }, intent: "reveal" })!;
const paneId = store.getState().layoutByWorkspace[workspaceKey]!.focusedPaneId;
store.getState().unfocusPane(workspaceKey);
store.getState().focusWorkspace(workspaceKey);
expect(store.getState().layoutByWorkspace[workspaceKey]!.focusedPaneId).toBe(paneId);
expect(store.getState().focusRequest).toEqual({ workspaceKey, tabId });
});
@@ -128,6 +128,8 @@ export type WorkspaceTabDestination =
| { kind: "sheet"; tabId: string };
interface WorkspaceLayoutStore {
/** Deliberate focus, including reselecting the current tab. Never persisted. */
focusRequest: { workspaceKey: string; tabId: string } | null;
panelRevisionByWorkspace: Record<string, Record<string, number>>;
/** Published once by the app shell; callers never pass a form factor to open a tab. */
layoutMode: WorkspaceLayoutMode;
@@ -210,6 +212,7 @@ interface WorkspaceLayoutStore {
* (archiving agents, killing terminals) first. No-ops on the last visible pane.
*/
closePane: (workspaceKey: string, paneId: string) => void;
focusWorkspace: (workspaceKey: string) => void;
focusPane: (workspaceKey: string, paneId: string) => void;
unfocusPane: (workspaceKey: string) => string | null;
restorePaneFocus: (workspaceKey: string, token: string) => void;
@@ -1083,6 +1086,7 @@ export function createWorkspaceLayoutStore(
},
layoutMode: "split-panes",
sheetsByWorkspace: {},
focusRequest: null,
layoutByWorkspace: {},
splitSizesByWorkspace: {},
explorerSidebarWidthByWorkspace: {},
@@ -1135,6 +1139,7 @@ export function createWorkspaceLayoutStore(
});
set((state) => ({
...withSheets(state, normalizedWorkspaceKey, next),
focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: sheet.tab.tabId },
...advancePanelRevision(
state,
normalizedWorkspaceKey,
@@ -1198,6 +1203,9 @@ export function createWorkspaceLayoutStore(
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
...advancePanelRevision(state, normalizedWorkspaceKey, result.tabId, normalizedTarget),
...(input.intent === "background"
? {}
: { focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: result.tabId } }),
// A tab brought to the front dismisses the sheet over it.
...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)),
hiddenAgentIdsByWorkspace:
@@ -1506,18 +1514,16 @@ export function createWorkspaceLayoutStore(
} else {
nextLayout = focusTabInLayout({ layout, tabId: normalizedTabId });
}
if (!nextLayout) {
return state;
}
if (!tabPane) return state;
return {
focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: normalizedTabId },
...withoutFocusRestoration(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
layoutByWorkspace: nextLayout
? { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout }
: state.layoutByWorkspace,
};
});
},
@@ -1535,13 +1541,15 @@ export function createWorkspaceLayoutStore(
paneId: normalizedPaneId,
tabId: normalizedTabId,
});
return nextLayout
const pane = findPaneById(layout.root, normalizedPaneId);
return pane?.tabIds.includes(normalizedTabId)
? {
focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: normalizedTabId },
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
[normalizedWorkspaceKey]: nextLayout ?? layout,
},
}
: state;
@@ -1971,6 +1979,13 @@ export function createWorkspaceLayoutStore(
};
});
},
focusWorkspace: (workspaceKey) => {
const state = get();
const paneId =
state.layoutByWorkspace[workspaceKey]?.focusedPaneId ??
state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId;
if (paneId) state.focusPane(workspaceKey, paneId);
},
focusPane: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedPaneId = trimNonEmpty(paneId);
@@ -1991,16 +2006,17 @@ export function createWorkspaceLayoutStore(
layout,
paneId: normalizedPaneId,
});
if (!nextLayout) {
return state;
}
const pane = findPaneById(layout.root, normalizedPaneId);
if (!pane) return state;
return {
focusRequest: pane.focusedTabId
? { workspaceKey: normalizedWorkspaceKey, tabId: pane.focusedTabId }
: null,
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
layoutByWorkspace: nextLayout
? { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout }
: state.layoutByWorkspace,
};
});
},
@@ -431,3 +431,32 @@ describe("createArchiveFinishedSubagents", () => {
expect(descriptors.get("running")).toBe(running);
});
});
it("excludes live background work and rechecks it before each archive", async () => {
const first = deferred<void>();
const active = { ...paseo("active"), backgroundWork: { count: 1 } };
const current = new Map([
["first", managed("first")],
["later", managed("later")],
["active", managed("active", "idle", { backgroundWork: { count: 1 } })],
]);
const calls: string[] = [];
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("later"), active], {
parentAgentId: "parent",
getManagedSubagent: ({ agentId }) => current.get(agentId),
archiveManagedSubagent: ({ agentId }) => {
calls.push(agentId);
return first.promise;
},
dismissProviderSubagents: () => undefined,
});
expect(archive.getState().eligibleCount).toBe(2);
const operation = archive.archiveFinished();
current.set("later", managed("later", "idle", { backgroundWork: { count: 1 } }));
first.resolve();
expect(await operation).toMatchObject({
archivedPaseoIds: ["first"],
skippedPaseoIds: ["later"],
});
expect(calls).toEqual(["first"]);
});
@@ -19,7 +19,10 @@ export interface ArchiveFinishedOutcome {
failures: Array<{ id: string; error: unknown }>;
}
export type ManagedSubagentSnapshot = Pick<Agent, "id" | "status" | "parentAgentId" | "archivedAt">;
export type ManagedSubagentSnapshot = Pick<
Agent,
"id" | "status" | "parentAgentId" | "archivedAt" | "backgroundWork"
>;
export interface ArchiveFinishedSubagentsDeps {
parentAgentId: string;
@@ -36,7 +39,10 @@ export interface ArchiveFinishedSubagents {
}
export function isFinishedSubagent(row: SubagentRow): boolean {
if (row.kind === "paseo") return row.status === "idle" || row.status === "error";
if (row.kind === "paseo")
return (
(row.status === "idle" || row.status === "error") && (row.backgroundWork?.count ?? 0) === 0
);
return row.status === "completed" || row.status === "failed" || row.status === "canceled";
}
@@ -48,6 +54,7 @@ function canArchiveManagedSubagent(
agent &&
!agent.archivedAt &&
agent.parentAgentId === parentAgentId &&
(agent.backgroundWork?.count ?? 0) === 0 &&
(agent.status === "idle" || agent.status === "error"),
);
}
@@ -203,3 +203,19 @@ describe("requestArchiveSubagent", () => {
expect(env.recordedErrors).toEqual([error]);
});
});
it("warns before archiving an idle subagent with live background work", async () => {
const env = createFakeEnv({
initialSubagents: [
{
id: "child",
snapshot: { title: "Review branch", status: "idle", backgroundWork: { count: 1 } },
},
],
});
await requestArchiveSubagent({ serverId: "server-1", subagentId: "child" }, env.deps);
expect(env.recordedConfirmInputs[0]).toMatchObject({
title: "Archive running subagent?",
message: expect.stringContaining("will stop"),
});
});
@@ -4,6 +4,7 @@ import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
export interface ResolveArchiveSubagentDialogInput {
title: Agent["title"] | null | undefined;
status: Agent["status"] | null | undefined;
backgroundWork?: Agent["backgroundWork"];
}
function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null {
@@ -24,7 +25,7 @@ export function resolveArchiveSubagentDialog(
input: ResolveArchiveSubagentDialogInput,
): ConfirmDialogInput {
const subagentLabel = resolveSubagentLabel(input.title) ?? "this subagent";
const isRunning = input.status === "running";
const isRunning = input.status === "running" || (input.backgroundWork?.count ?? 0) > 0;
return {
title: isRunning ? "Archive running subagent?" : "Archive subagent?",
@@ -58,6 +59,7 @@ export async function requestArchiveSubagent(
resolveArchiveSubagentDialog({
title: subagent?.title,
status: subagent?.status,
backgroundWork: subagent?.backgroundWork,
}),
);
if (!confirmed) {
@@ -354,6 +354,7 @@ describe("selectSubagentsForParent", () => {
provider: "claude",
title: "Review child",
status: "running",
backgroundWork: { count: 1 },
requiresAttention: true,
createdAt,
model: "should-not-leak",
@@ -380,6 +381,7 @@ describe("selectSubagentsForParent", () => {
description: null,
subtitle: null,
status: "running",
backgroundWork: { count: 1 },
turn: { phase: "idle", cancellationRequestId: null },
requiresAttention: true,
createdAt,
@@ -387,6 +389,7 @@ describe("selectSubagentsForParent", () => {
]);
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
"address",
"backgroundWork",
"createdAt",
"description",
"id",
+2
View File
@@ -18,6 +18,7 @@ export interface PaseoSubagentRow {
description: null;
subtitle: null;
status: Agent["status"];
backgroundWork?: Agent["backgroundWork"];
turn: Agent["turn"];
requiresAttention: Agent["requiresAttention"];
createdAt: Agent["createdAt"];
@@ -65,6 +66,7 @@ function toSubagentRow(agent: Agent): SubagentRow {
description: null,
subtitle: null,
status: agent.status,
backgroundWork: agent.backgroundWork,
turn: agent.turn,
requiresAttention: agent.requiresAttention,
createdAt: agent.createdAt,
@@ -26,6 +26,7 @@ function row(
? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }
: { phase: "idle", cancellationRequestId: null }),
requiresAttention: overrides.requiresAttention ?? false,
backgroundWork: overrides.backgroundWork,
createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"),
};
}
@@ -193,6 +194,22 @@ describe("buildSubagentRowPresentationData", () => {
);
});
it("shows a child with background work as running after its foreground turn ends", () => {
expect(
buildSubagentRowPresentationData(
row({ id: "a", status: "idle", backgroundWork: { count: 1 } }),
).statusBucket,
).toBe("running");
});
it("keeps a failed child marked failed while it has background work", () => {
expect(
buildSubagentRowPresentationData(
row({ id: "a", status: "error", backgroundWork: { count: 1 } }),
).statusBucket,
).toBe("failed");
});
it("maps an idle row to the done status bucket so callers render the static provider icon", () => {
expect(buildSubagentRowPresentationData(row({ id: "a", status: "idle" })).statusBucket).toBe(
"done",
@@ -19,9 +19,12 @@ function presentationStatus(row: SubagentRow) {
* A child's mark reports what it is doing, not whether anyone read it: the track is where a
* failed child stays visible after the parent's own attention is cleared.
*/
export function deriveSubagentStatusBucket(status: AgentLifecycleStatus): SidebarStateBucket {
export function deriveSubagentStatusBucket(
status: AgentLifecycleStatus,
backgroundWork?: { count: number } | null,
): SidebarStateBucket {
if (status === "error") return "failed";
if (status === "running") return "running";
if (status === "running" || (backgroundWork?.count ?? 0) > 0) return "running";
return "done";
}
@@ -49,7 +52,10 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP
label: label ?? "",
subtitle: subtitle ?? "",
titleState: label ? "ready" : "loading",
statusBucket: deriveSubagentStatusBucket(status),
statusBucket: deriveSubagentStatusBucket(
status,
row.kind === "paseo" ? row.backgroundWork : undefined,
),
};
}
@@ -6,6 +6,7 @@ export type AgentDirectoryEntry = Pick<
| "serverId"
| "title"
| "status"
| "backgroundWork"
| "turn"
| "lastActivityAt"
| "cwd"
+2
View File
@@ -812,6 +812,8 @@ export interface PluginTimelineStreamItem {
id: string;
/** The id of the stream item a timeline transformer replaced; set only by projection. */
sourceId?: string;
/** The original row kind, retained by projection for turn folding. */
sourceKind?: StreamItem["kind"];
timelineCursor?: TimelinePosition;
turnId?: string;
timestamp: Date;
+99 -251
View File
@@ -1,269 +1,117 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { pickAttentionAgent, shouldClearAgentAttention } from "@/utils/agent-attention";
import { createAgentAttentionController } from "./agent-attention";
function createAgent(input: Partial<Agent> & Pick<Agent, "id">): Agent {
const { id, ...rest } = input;
return {
serverId: "server-1",
id,
provider: "codex",
status: "idle",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-01-01T00:00:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
cwd: "/repo/worktree",
model: null,
parentAgentId: null,
labels: {},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
...rest,
turn: rest.turn ?? { phase: "idle", cancellationRequestId: null },
};
}
describe("agent attention acknowledgement", () => {
function setup() {
const calls: string[] = [];
const errors: unknown[] = [];
const controller = createAgentAttentionController({
clear: async () => {
calls.push("clear");
},
onError: (failure) => errors.push(failure),
});
const state = {
focused: true,
ready: true,
requiresAttention: false,
attentionReason: "finished" as const,
focusRequest: null as object | null,
};
return { controller, state, calls, errors };
}
describe("shouldClearAgentAttention", () => {
it("returns true only when the agent is connected and requires attention", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe(true);
it("acknowledges entry, but keeps attention arriving while focused until another focus action", async () => {
const { controller, state, calls } = setup();
controller.update(state);
state.requiresAttention = true;
controller.update(state);
expect(calls).toEqual([]);
controller.update({ ...state, focusRequest: {} });
expect(calls).toEqual(["clear"]);
await Promise.resolve();
controller.update({ ...state, focusRequest: {} });
expect(calls).toEqual(["clear", "clear"]);
});
it("returns false when the app is disconnected", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: false,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe(false);
it("clears on entry and never clears on leaving", async () => {
const { controller, state, calls } = setup();
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual(["clear"]);
await Promise.resolve();
controller.update({ ...state, focused: false, requiresAttention: true });
expect(calls).toEqual(["clear"]);
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual(["clear", "clear"]);
});
it("returns false when attention is already clear", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: false,
attentionReason: null,
}),
).toBe(false);
it("waits for connection and agent data before consuming entry", () => {
const { controller, state, calls } = setup();
controller.update({ ...state, ready: false });
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual(["clear"]);
});
it("returns false for empty agent ids", () => {
expect(
shouldClearAgentAttention({
agentId: "",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe(false);
it("does not treat reconnecting as another focus action", () => {
const { controller, state, calls } = setup();
controller.update(state);
controller.update({ ...state, requiresAttention: true });
controller.update({ ...state, requiresAttention: true, ready: false });
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual([]);
controller.focus();
expect(calls).toEqual(["clear"]);
});
it("returns false for permission-shaped attention", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "permission",
}),
).toBe(false);
it("holds a repeat focus while cached data is waiting for synchronization", () => {
const { controller, state, calls } = setup();
controller.update(state);
controller.update({ ...state, ready: false });
controller.focus();
controller.update({ ...state, ready: false, requiresAttention: true });
expect(calls).toEqual([]);
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual(["clear"]);
});
it("returns false for focus entry when clear was deferred while already focused", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
trigger: "focus-entry",
hasDeferredFocusEntryClear: true,
}),
).toBe(false);
it("drops a pending focus when the user leaves before fresh data arrives", () => {
const { controller, state, calls } = setup();
controller.update({ ...state, ready: false });
controller.update({ ...state, focused: false, requiresAttention: true });
expect(calls).toEqual([]);
});
it("returns true for explicit follow-up entrypoints after deferred focus clear", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
trigger: "input-focus",
hasDeferredFocusEntryClear: true,
}),
).toBe(true);
it("keeps permission attention and deduplicates an in-flight acknowledgement", () => {
const { controller, state, calls } = setup();
controller.update({ ...state, requiresAttention: true, attentionReason: "permission" });
controller.focus();
expect(calls).toEqual([]);
controller.update({ ...state, requiresAttention: true });
controller.focus();
controller.focus();
expect(calls).toEqual(["clear"]);
});
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
trigger: "prompt-send",
hasDeferredFocusEntryClear: true,
}),
).toBe(true);
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
trigger: "agent-blur",
hasDeferredFocusEntryClear: true,
}),
).toBe(true);
});
});
describe("pickAttentionAgent", () => {
it("returns null for empty input", () => {
expect(pickAttentionAgent([])).toBeNull();
});
it("returns null when no agent requires attention", () => {
expect(
pickAttentionAgent([
createAgent({ id: "agent-1", requiresAttention: false, attentionReason: "permission" }),
createAgent({ id: "agent-2", requiresAttention: false, attentionReason: null }),
]),
).toBeNull();
});
it("returns the single permission agent", () => {
expect(
pickAttentionAgent([
createAgent({
id: "agent-1",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-03T00:00:00.000Z"),
}),
]),
).toBe("agent-1");
});
it("prioritizes permission over error over finished", () => {
expect(
pickAttentionAgent([
createAgent({
id: "finished-agent",
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
createAgent({
id: "error-agent",
requiresAttention: true,
attentionReason: "error",
attentionTimestamp: new Date("2026-01-02T00:00:00.000Z"),
}),
createAgent({
id: "permission-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-03T00:00:00.000Z"),
}),
]),
).toBe("permission-agent");
});
it("breaks ties by oldest attention timestamp", () => {
expect(
pickAttentionAgent([
createAgent({
id: "newer-permission-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-03T00:00:00.000Z"),
}),
createAgent({
id: "older-permission-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
]),
).toBe("older-permission-agent");
});
it("ignores subagents even when they require attention", () => {
expect(
pickAttentionAgent([
createAgent({
id: "subagent",
parentAgentId: "parent-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
createAgent({
id: "top-level-agent",
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: new Date("2026-01-02T00:00:00.000Z"),
}),
]),
).toBe("top-level-agent");
});
it("returns null when only subagents require attention", () => {
expect(
pickAttentionAgent([
createAgent({
id: "subagent",
parentAgentId: "parent-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
]),
).toBeNull();
});
it("ignores agents with requiresAttention true but null reason", () => {
expect(
pickAttentionAgent([
createAgent({
id: "ignored-agent",
requiresAttention: true,
attentionReason: null,
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
createAgent({
id: "error-agent",
requiresAttention: true,
attentionReason: "error",
attentionTimestamp: new Date("2026-01-02T00:00:00.000Z"),
}),
]),
).toBe("error-agent");
it("reports failure and allows the next interaction to retry", async () => {
const errors: unknown[] = [];
let calls = 0;
const error = new Error("Disconnected");
const controller = createAgentAttentionController({
clear: async () => {
if (++calls === 1) throw error;
},
onError: (failure) => errors.push(failure),
});
controller.update({
focused: true,
ready: true,
requiresAttention: true,
attentionReason: "finished",
focusRequest: null,
});
await Promise.resolve();
expect(errors).toEqual([error]);
controller.focus();
expect(calls).toBe(2);
});
});
+43 -75
View File
@@ -1,81 +1,49 @@
import type { Agent } from "@/stores/session-store";
interface ShouldClearAgentAttentionInput {
agentId: string | null | undefined;
isConnected: boolean;
requiresAttention: boolean | null | undefined;
attentionReason?: "finished" | "error" | "permission" | null | undefined;
trigger?: AgentAttentionClearTrigger;
hasDeferredFocusEntryClear?: boolean;
interface AttentionState {
focused: boolean;
ready: boolean;
requiresAttention: boolean;
attentionReason: "finished" | "error" | "permission" | null | undefined;
focusRequest: object | null;
}
export type AgentAttentionClearTrigger =
| "focus-entry"
| "input-focus"
| "prompt-send"
| "agent-blur";
export function createAgentAttentionController(deps: {
clear: () => Promise<void>;
onError: (error: unknown) => void;
}) {
let state: AttentionState | null = null;
let inFlight = false;
let pendingFocus = false;
const ATTENTION_REASON_PRIORITY = {
permission: 0,
error: 1,
finished: 2,
} as const;
function getAttentionPriority(reason: Agent["attentionReason"]): number | null {
if (!reason) {
return null;
function acknowledgeFocus() {
if (!pendingFocus || !state?.focused || !state.ready) return;
pendingFocus = false;
if (!state.requiresAttention || state.attentionReason === "permission" || inFlight) return;
inFlight = true;
void deps.clear().then(
() => {
inFlight = false;
return undefined;
},
(error: unknown) => {
inFlight = false;
deps.onError(error);
},
);
}
return ATTENTION_REASON_PRIORITY[reason];
}
export function pickAttentionAgent(agents: Agent[]): string | null {
let selectedAgentId: string | null = null;
let selectedPriority = Number.POSITIVE_INFINITY;
let selectedTimestamp = Number.POSITIVE_INFINITY;
for (const agent of agents) {
if (agent.requiresAttention !== true) {
continue;
}
if (agent.parentAgentId) {
continue;
}
const priority = getAttentionPriority(agent.attentionReason);
if (priority === null) {
continue;
}
const timestamp = agent.attentionTimestamp?.getTime() ?? Number.POSITIVE_INFINITY;
const isHigherPriority = priority < selectedPriority;
const isOlderAtSamePriority = priority === selectedPriority && timestamp < selectedTimestamp;
if (isHigherPriority || isOlderAtSamePriority) {
selectedAgentId = agent.id;
selectedPriority = priority;
selectedTimestamp = timestamp;
}
}
return selectedAgentId;
}
export function shouldClearAgentAttention(input: ShouldClearAgentAttentionInput): boolean {
const agentId = input.agentId?.trim();
if (!agentId) {
return false;
}
if (!input.isConnected) {
return false;
}
if (!input.requiresAttention) {
return false;
}
if (input.attentionReason === "permission") {
return false;
}
if (input.trigger === "focus-entry" && input.hasDeferredFocusEntryClear === true) {
return false;
}
return true;
return {
focus() {
if (!state?.focused) return;
pendingFocus = true;
acknowledgeFocus();
},
update(next: AttentionState) {
const entered = next.focused && !state?.focused;
const requested = next.focusRequest !== null && next.focusRequest !== state?.focusRequest;
state = { ...next };
if (!next.focused) pendingFocus = false;
else if (entered || requested) pendingFocus = true;
acknowledgeFocus();
},
};
}
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import {
getAutocompleteFallbackIndex,
orderAutocompleteOptions,
} from "@/components/ui/autocomplete-utils";
import {
applySlashCommandReplacement,
filterAndRankCommandAutocompleteEntries,
@@ -33,6 +37,53 @@ describe("filterAndRankCommandAutocompleteEntries", () => {
});
});
describe("filterAndRankCommandAutocompleteEntries on an empty query", () => {
const rootEntries = [
{ source: "client" as const, command: { name: "exit", aliases: ["quit", "q"] } },
{ source: "client" as const, command: { name: "clear", aliases: ["new"] } },
{ source: "plugin" as const, command: { name: "handoff" } },
{ source: "provider" as const, command: { name: "compact" } },
{ source: "provider" as const, command: { name: "review" } },
];
function selectedOnEnter(query: string) {
const listed = orderAutocompleteOptions(
filterAndRankCommandAutocompleteEntries(rootEntries, query),
);
return listed[getAutocompleteFallbackIndex(listed.length)]?.command.name;
}
it("selects a provider command, never an immediate Paseo action", () => {
expect(selectedOnEnter("")).toBe("compact");
});
it("lists Paseo actions farthest from the input", () => {
const listed = orderAutocompleteOptions(
filterAndRankCommandAutocompleteEntries(rootEntries, ""),
);
expect(listed.map((entry) => entry.command.name)).toEqual([
"clear",
"exit",
"handoff",
"review",
"compact",
]);
});
it("still selects a typed Paseo action", () => {
expect(selectedOnEnter("exit")).toBe("exit");
});
it("waits for a search when Paseo actions are all there is", () => {
const clientOnly = rootEntries.filter((entry) => entry.source === "client");
expect(filterAndRankCommandAutocompleteEntries(clientOnly, "")).toEqual([]);
expect(
filterAndRankCommandAutocompleteEntries(clientOnly, "ex").map((entry) => entry.command.name),
).toEqual(["exit"]);
});
});
describe("findActiveSlashCommand", () => {
it("detects a slash command token in the middle of the prompt", () => {
const text = "use /tas before implementation";
@@ -4,7 +4,10 @@ import {
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
type CommandAutocompleteSource = "provider" | "plugin" | "client";
interface CommandAutocompleteEntry {
source: CommandAutocompleteSource;
command: {
name: string;
aliases?: readonly string[];
@@ -12,9 +15,14 @@ interface CommandAutocompleteEntry {
};
}
interface InlineSkillCommandEntry extends CommandAutocompleteEntry {
source: "provider" | "client";
}
// The list renders reversed above the input with the first entry selected. Paseo's own actions
// run at once (`/exit` archives), so an empty query never selects one: they sort farthest from
// the input, and with nothing else to list the menu waits for a search.
const EMPTY_QUERY_SOURCE_ORDER: Record<CommandAutocompleteSource, number> = {
provider: 0,
plugin: 1,
client: 2,
};
export type SlashCommandPosition = "start" | "inline";
@@ -54,7 +62,11 @@ export function filterAndRankCommandAutocompleteEntries<TEntry extends CommandAu
): TEntry[] {
const normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.length === 0) {
return [...entries];
if (entries.every((entry) => entry.source === "client")) return [];
// Hermes has no `toSorted`.
return [...entries].sort(
(a, b) => EMPTY_QUERY_SOURCE_ORDER[a.source] - EMPTY_QUERY_SOURCE_ORDER[b.source],
);
}
const scoredEntries: ScoredCommandAutocompleteEntry<TEntry>[] = [];
@@ -76,7 +88,7 @@ export function filterAndRankCommandAutocompleteEntries<TEntry extends CommandAu
return scoredEntries.map((scored) => scored.entry);
}
export function filterInlineSkillCommandEntries<TEntry extends InlineSkillCommandEntry>(
export function filterInlineSkillCommandEntries<TEntry extends CommandAutocompleteEntry>(
entries: readonly TEntry[],
): TEntry[] {
return entries.filter((entry) => entry.source === "provider" && entry.command.kind === "skill");
@@ -10,6 +10,7 @@ function makeAgent(overrides: Partial<AggregatedAgent> = {}): AggregatedAgent {
serverLabel: (overrides as { serverLabel?: string }).serverLabel ?? "server",
title: overrides.title ?? null,
status: overrides.status ?? ("running" as AggregatedAgent["status"]),
backgroundWork: overrides.backgroundWork,
turn:
overrides.turn ??
({
@@ -71,6 +72,19 @@ describe("deriveProjectDisplayName", () => {
});
describe("groupAgents", () => {
it("keeps an idle agent with long-running background work in the active group", () => {
const agent = makeAgent({
status: "idle",
turn: { phase: "idle", cancellationRequestId: null },
lastActivityAt: new Date("2020-01-01T00:00:00.000Z"),
backgroundWork: { count: 1 },
});
expect(
groupAgents([agent])
.activeGroups.flatMap((group) => group.agents)
.map((item) => item.id),
).toEqual([agent.id]);
});
it("uses turn liveness instead of protocol status for active grouping", () => {
const old = new Date("2020-01-01T00:00:00.000Z");
const open = makeAgent({ id: "open", status: "idle", lastActivityAt: old });
+13 -5
View File
@@ -1,4 +1,5 @@
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state";
/**
* Derives the project key for grouping agents.
@@ -262,12 +263,19 @@ const MAX_INACTIVE_PER_PROJECT = 5;
* - All truly active agents (running/needs input/requires attention) are always shown
* - Recently active (within grace period but not truly active) are limited to MAX_INACTIVE_PER_PROJECT
*/
function activityStatus(agent: AggregatedAgent): AggregatedAgent["status"] {
if (agent.turn.phase === "open") return "running";
return agent.status === "running" ? "idle" : agent.status;
}
function isAgentTrulyActive(agent: AggregatedAgent): boolean {
return (
agent.turn.phase === "open" ||
agent.requiresAttention ||
(agent.pendingPermissionCount ?? 0) > 0
);
return isSidebarActiveAgent({
status: activityStatus(agent),
backgroundWork: agent.backgroundWork,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
pendingPermissionCount: agent.pendingPermissionCount,
});
}
function partitionAgentsByActivity(
@@ -70,6 +70,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null,
status: agent.status,
backgroundWork: agent.backgroundWork,
...projectActiveTurn(agent),
capabilities: agent.capabilities,
currentModeId: agent.currentModeId,
@@ -111,6 +112,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
id: snapshot.id,
provider: snapshot.provider,
status: snapshot.status,
backgroundWork: snapshot.backgroundWork,
turn,
createdAt,
updatedAt,
@@ -40,6 +40,7 @@ export function buildWorkspaceAgentActivityIndex(
const status = deriveSidebarStateBucket({
status: workspaceAgentStatus(agent),
backgroundWork: agent.backgroundWork,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
@@ -111,6 +111,7 @@ export class LegacyWorkspaces {
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
backgroundWork: agent.backgroundWork,
});
const existing = workspaces.get(id);
if (
+55
View File
@@ -1049,6 +1049,9 @@ type CorrelatedResponsePayloads = {
};
type CorrelatedResponsePayload<TType extends CorrelatedResponseType> =
CorrelatedResponsePayloads[TType];
export type AgentBackgroundWorkListPayload =
CorrelatedResponsePayload<"agent.background_work.list.response">;
export type AgentBackgroundWorkItem = AgentBackgroundWorkListPayload["items"][number];
type PluginCatalogResponseEntry =
CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number];
@@ -3869,6 +3872,58 @@ export class DaemonClient {
return payload;
}
async listAgentBackgroundWork(
agentId: string,
options: { requestId?: string; timeout?: number } = {},
): Promise<AgentBackgroundWorkItem[]> {
this.assertAgentBackgroundWorkSupported();
const payload =
await this.sendNamespacedCorrelatedSessionRequest<"agent.background_work.list.response">({
requestId: options.requestId,
timeout: options.timeout,
message: { type: "agent.background_work.list.request", agentId },
});
if (payload.error) throw new Error(payload.error);
return payload.items;
}
async stopAgentBackgroundWork(
agentId: string,
item: AgentBackgroundWorkItem,
options: { requestId?: string; timeout?: number } = {},
): Promise<void> {
// COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03.
if (this.lastServerInfoMessage?.features?.agentBackgroundWorkStop !== true)
throw new Error("Update the host to stop background work.");
if (!item.canStop || !item.stopToken) throw new Error("Background work cannot be stopped.");
const payload =
await this.sendNamespacedCorrelatedSessionRequest<"agent.background_work.stop.response">({
requestId: options.requestId,
timeout: options.timeout ?? 30000,
message: {
type: "agent.background_work.stop.request",
agentId,
itemId: item.id,
stopToken: item.stopToken,
},
});
if (payload.error) throw new Error(payload.error);
}
observeAgentBackgroundWork(
options: { signal?: AbortSignal } = {},
): OwnedSubscription<CorrelatedResponsePayload<"session.events.set_subscription.response">> {
this.assertAgentBackgroundWorkSupported();
return this.observeEvents(["agent.background_work.update"], options);
}
private assertAgentBackgroundWorkSupported(): void {
// COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06.
if (this.lastServerInfoMessage?.features?.agentBackgroundWork !== true) {
throw new Error("Update the host to see an agent's background work.");
}
}
async fetchProviderSubagentTimeline(
parentAgentId: string,
subagentId: string,
+253
View File
@@ -1938,3 +1938,256 @@ test("agent reload reopens through the daemon RPC then returns the observed snap
await client.close();
}
});
const BACKGROUND_WORK_FEATURES = { ownedSubscriptions: true, agentBackgroundWork: true };
const SHELL_ITEM = {
id: "bash-1",
kind: "shell",
description: "sleep 20",
startedAt: "2026-10-06T10:00:00.000Z",
};
function answerBackgroundWorkList(ws: FakeWebSocket, items: unknown[]): void {
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({ type: "agent.background_work.list.request", agentId: "agent-a" });
ws.message(
sessionMessage({
type: "agent.background_work.list.response",
payload: { requestId: request.requestId, agentId: "agent-a", items, error: null },
}),
);
}
test("agent handles list background work", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const pending = client.agents.ref("agent-a").backgroundWork.list();
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({ type: "agent.background_work.list.request", agentId: "agent-a" });
ws.message(
sessionMessage({
type: "agent.background_work.list.response",
payload: {
requestId: request.requestId,
agentId: "agent-a",
items: [SHELL_ITEM],
error: null,
},
}),
);
await expect(pending).resolves.toEqual([SHELL_ITEM]);
await client.close();
});
test("agent handles follow only their own background work", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
const unsubscribe = client.agents
.ref("agent-a")
.backgroundWork.subscribe((items) => received.push(items));
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "session.events.set_subscription.request",
events: ["agent.background_work.update"],
});
acknowledgeObservation(ws, "background-work-sdk");
answerBackgroundWorkList(ws, []);
await vi.waitFor(() => expect(received).toEqual([[]]));
ws.message(
sessionMessage({
type: "agent.background_work.update",
payload: { subscriptionId: "background-work-sdk", agentId: "agent-b", items: [SHELL_ITEM] },
}),
);
ws.message(
sessionMessage({
type: "agent.background_work.update",
payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [SHELL_ITEM] },
}),
);
await vi.waitFor(() => expect(received).toEqual([[], [SHELL_ITEM]]));
unsubscribe();
ws.message(
sessionMessage({
type: "agent.background_work.update",
payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [] },
}),
);
expect(received).toEqual([[], [SHELL_ITEM]]);
await client.close();
});
test("background work subscriptions list the current work again after a reconnect", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items));
acknowledgeObservation(ws, "background-work-first");
answerBackgroundWorkList(ws, [SHELL_ITEM]);
await vi.waitFor(() => expect(received).toEqual([[SHELL_ITEM]]));
ws.readyState = 3;
ws.onclose?.({ code: 1006, reason: "Connection lost" });
const reconnecting = client.connect();
const next = FakeWebSocket.instances[1];
next.open();
next.message(
sessionMessage({
type: "status",
payload: {
status: "server_info",
serverId: "srv_sdk_test",
hostname: null,
version: null,
features: BACKGROUND_WORK_FEATURES,
},
}),
);
await reconnecting;
await vi.waitFor(() =>
expect(parseSentSessionMessage(next.sent.at(-1))).toMatchObject({
type: "session.events.set_subscription.request",
}),
);
acknowledgeObservation(next, "background-work-second");
answerBackgroundWorkList(next, []);
await vi.waitFor(() => expect(received).toEqual([[SHELL_ITEM], []]));
await client.close();
});
test("background work subscriptions keep an update that overtakes their list", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items));
acknowledgeObservation(ws, "background-work-sdk");
await vi.waitFor(() =>
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "agent.background_work.list.request",
}),
);
const listRequest = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "agent.background_work.update",
payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [] },
}),
);
ws.message(
sessionMessage({
type: "agent.background_work.list.response",
payload: {
requestId: listRequest.requestId,
agentId: "agent-a",
items: [SHELL_ITEM],
error: null,
},
}),
);
await vi.waitFor(() => expect(received).toEqual([[]]));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(received).toEqual([[]]);
await client.close();
});
test("background work subscriptions report a failed setup to onError", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
const errors: unknown[] = [];
client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items), {
onError: (error) => errors.push(error),
});
const request = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "session.events.set_subscription.response",
payload: { requestId: request.requestId },
}),
);
await vi.waitFor(() => expect(errors).toHaveLength(1));
expect((errors[0] as Error).message).toContain("subscription ID");
expect(received).toEqual([]);
await client.close();
});
test("background work subscriptions report a failed list to onError", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
const errors: unknown[] = [];
client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items), {
onError: (error) => errors.push(error),
});
acknowledgeObservation(ws, "background-work-sdk");
await vi.waitFor(() =>
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "agent.background_work.list.request",
}),
);
const listRequest = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "agent.background_work.list.response",
payload: { requestId: listRequest.requestId, agentId: "agent-a", items: [], error: "boom" },
}),
);
await vi.waitFor(() => expect(errors).toHaveLength(1));
expect(errors[0]).toBeInstanceOf(Error);
expect((errors[0] as Error).message).toContain("boom");
expect(received).toEqual([]);
await client.close();
});
test("background work asks for a host update when the daemon lacks it", async () => {
const { client } = await connectClient({ ownedSubscriptions: true });
const handle = client.agents.ref("agent-a");
await expect(handle.backgroundWork.list()).rejects.toThrow("Update the host");
expect(() => handle.backgroundWork.subscribe(() => {})).toThrow("Update the host");
await client.close();
});
test("background stop sends the live item token and waits for the response", async () => {
const { client, ws } = await connectClient({
...BACKGROUND_WORK_FEATURES,
agentBackgroundWorkStop: true,
});
const item = { ...SHELL_ITEM, canStop: true, stopToken: "generation-1" };
const pending = client.agents.ref("agent-a").backgroundWork.stop(item);
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "agent.background_work.stop.request",
agentId: "agent-a",
itemId: item.id,
stopToken: item.stopToken,
});
ws.message(
sessionMessage({
type: "agent.background_work.stop.response",
payload: {
requestId: request.requestId,
agentId: "agent-a",
itemId: item.id,
error: "rejected",
},
}),
);
await expect(pending).rejects.toThrow("rejected");
await client.close();
});
test("background stop rejects when the host does not advertise the operation", async () => {
const { client } = await connectClient(BACKGROUND_WORK_FEATURES);
await expect(
client.agents
.ref("agent-a")
.backgroundWork.stop({ ...SHELL_ITEM, canStop: true, stopToken: "generation" }),
).rejects.toThrow("Update the host");
await client.close();
});
+76
View File
@@ -54,6 +54,7 @@ export type {
} from "./terminals/index.js";
import type { PluginTimelineItem } from "@getpaseo/protocol/agent-types";
import type {
AgentBackgroundWorkItem,
FetchAgentsEntry,
FetchAgentsOptions,
FetchAgentsPageInfo,
@@ -353,6 +354,36 @@ export interface PaseoAgentTimelineHandle {
subscribe(handler: (event: PaseoAgentTimelineEvent) => void): PaseoAgentTimelineSubscription;
}
export type PaseoAgentBackgroundWorkItem = AgentBackgroundWorkItem;
export interface PaseoAgentBackgroundWorkHandle {
/** Stop one item and wait for provider confirmation. */
stop(
item: PaseoAgentBackgroundWorkItem,
options?: { requestId?: string; timeout?: number },
): Promise<void>;
/** Reads the agent's current background work once. */
list(options?: { requestId?: string; timeout?: number }): Promise<PaseoAgentBackgroundWorkItem[]>;
/**
* Calls `listener` with the agent's current list once the subscription is established, again
* after each reconnect, then with the full list each time it changes. Returns the unsubscribe
* function.
*
* When reading the current list fails, `options.onError` receives the error and `listener` is
* not called; without `onError` the error is logged. The read is not retried: the next
* reconnect reads the list again.
*/
subscribe(
listener: (items: PaseoAgentBackgroundWorkItem[]) => void,
options?: PaseoAgentBackgroundWorkSubscribeOptions,
): () => void;
}
export interface PaseoAgentBackgroundWorkSubscribeOptions {
/** Receives the error when reading the agent's current list fails. */
onError?: (error: unknown) => void;
}
export interface PaseoAgentHandle {
readonly id: string;
readonly address: AgentAddress;
@@ -376,6 +407,7 @@ export interface PaseoAgentHandle {
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
readonly timeline: PaseoAgentTimelineHandle;
readonly backgroundWork: PaseoAgentBackgroundWorkHandle;
current(): PaseoAgent | null;
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
/**
@@ -612,6 +644,7 @@ export function createPaseoApi(
daemonClient,
listenAgents,
(agentId, handler) => own(() => daemonClient.subscribeAgentTimeline(agentId, handler)),
() => own(() => daemonClient.observeAgentBackgroundWork()),
);
const createAgent = async (
options: PaseoAgentCreateOptions,
@@ -893,6 +926,7 @@ function createAgentHandleFactory(
daemonClient: DaemonClient,
listen: (handler: PaseoAgentUpdateHandler) => () => void,
subscribeTimeline: DaemonClient["subscribeAgentTimeline"],
observeBackgroundWork: () => ReturnType<DaemonClient["observeAgentBackgroundWork"]>,
): AgentHandleFactory {
return (agent) => {
const id = typeof agent === "string" ? agent : agent.id;
@@ -939,6 +973,48 @@ function createAgentHandleFactory(
}
}),
},
backgroundWork: {
stop: (item, options) => daemonClient.stopAgentBackgroundWork(id, item, options),
list: (options) => daemonClient.listAgentBackgroundWork(id, options),
subscribe: (listener, options) => {
const observation = observeBackgroundWork();
// Bumped by every delivery so a list answered after a newer update is dropped.
let sequence = 0;
let released = false;
const reportError = (error: unknown) => {
if (released) return;
if (options?.onError) options.onError(error);
else console.error("Background work subscription failed", error);
};
observation.subscribe({
error: reportError,
snapshot: () => {
const listed = ++sequence;
void daemonClient
.listAgentBackgroundWork(id)
.then((items) => {
if (!released && listed === sequence) listener(items);
return undefined;
})
.catch(reportError);
},
update: (message) => {
if (message.type !== "agent.background_work.update") return;
if (message.payload.agentId !== id) return;
sequence++;
listener(message.payload.items);
},
});
return () => {
released = true;
void observation
.release()
.catch((error) =>
console.error("Background work subscription cleanup failed", error),
);
};
},
},
get workspaceId() {
return current?.workspaceId ?? null;
},
@@ -6,6 +6,30 @@ import {
} from "./agent-state-bucket.js";
describe("deriveAgentStateBucket", () => {
it("shows idle agents with background work as running while preserving attention precedence", () => {
const backgroundWork = { count: 1 };
expect(
deriveAgentStateBucket({
status: "idle",
backgroundWork,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe("running");
expect(
deriveAgentStateBucket({ status: "idle", backgroundWork, attentionReason: "permission" }),
).toBe("needs_input");
expect(deriveAgentStateBucket({ status: "error", backgroundWork })).toBe("running");
expect(
deriveAgentStateBucket({
status: "error",
backgroundWork,
requiresAttention: true,
attentionReason: "error",
}),
).toBe("failed");
expect(deriveAgentStateBucket({ status: "idle", backgroundWork: { count: 0 } })).toBe("done");
});
it("prioritizes pending permissions as needs_input", () => {
expect(
deriveAgentStateBucket({
+3 -2
View File
@@ -9,6 +9,7 @@ export interface AgentStateBucketInput {
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: AgentAttentionReason;
backgroundWork?: { count: number } | null;
}
const WORKSPACE_STATE_BUCKET_PRIORITY = {
@@ -32,7 +33,7 @@ export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceS
if (hasUnreadError(input)) {
return "failed";
}
if (input.status === "running") {
if (input.status === "running" || (input.backgroundWork?.count ?? 0) > 0) {
return "running";
}
if (input.requiresAttention) {
@@ -52,7 +53,7 @@ export function getAgentStatusPriority(input: AgentStateBucketInput): number {
if (hasUnreadError(input)) {
return 1;
}
if (input.status === "running") {
if (input.status === "running" || (input.backgroundWork?.count ?? 0) > 0) {
return 2;
}
if (input.status === "initializing") {
@@ -0,0 +1,103 @@
import { describe, expect, test } from "vitest";
import {
ServerInfoStatusPayloadSchema,
SessionEventSubscriptionSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
} from "./messages.js";
const ITEM = {
id: "bash-1",
kind: "shell",
description: "sleep 20",
startedAt: "2026-10-06T10:00:00.000Z",
};
describe("agent background work protocol", () => {
test("accepts a list request and its response", () => {
expect(
SessionInboundMessageSchema.parse({
type: "agent.background_work.list.request",
agentId: "agent-1",
requestId: "request-1",
}),
).toMatchObject({ agentId: "agent-1", requestId: "request-1" });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.background_work.list.response",
payload: { requestId: "request-1", agentId: "agent-1", items: [ITEM], error: null },
}),
).toMatchObject({ payload: { items: [ITEM] } });
});
test("accepts an update with or without a subscription id", () => {
for (const payload of [
{ agentId: "agent-1", items: [] },
{ subscriptionId: "events-1", agentId: "agent-1", items: [ITEM] },
]) {
expect(
SessionOutboundMessageSchema.parse({ type: "agent.background_work.update", payload }),
).toMatchObject({ payload });
}
});
test("keeps kind an open string so new kinds parse on old clients", () => {
expect(
SessionOutboundMessageSchema.parse({
type: "agent.background_work.update",
payload: { agentId: "agent-1", items: [{ ...ITEM, kind: "future-kind" }] },
}),
).toMatchObject({ payload: { items: [{ kind: "future-kind" }] } });
});
test("preserves an optional command while accepting older items", () => {
const withCommand = { ...ITEM, description: "Watch build", command: "tail -f build.log" };
const parsed = SessionOutboundMessageSchema.parse({
type: "agent.background_work.update",
payload: { agentId: "agent-1", items: [ITEM, withCommand] },
});
expect(parsed).toMatchObject({ payload: { items: [ITEM, withCommand] } });
});
test("is a subscribable event", () => {
expect(SessionEventSubscriptionSchema.parse("agent.background_work.update")).toBe(
"agent.background_work.update",
);
});
test("keeps the feature optional for older server info payloads", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "server-1",
features: {},
}).features?.agentBackgroundWork,
).toBeUndefined();
});
});
test("stop requests carry an inventory token and preserve legacy item parsing", () => {
expect(
SessionInboundMessageSchema.parse({
type: "agent.background_work.stop.request",
agentId: "a",
itemId: "x",
stopToken: "token",
requestId: "r",
}),
).toMatchObject({ stopToken: "token" });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.background_work.stop.response",
payload: { requestId: "r", agentId: "a", itemId: "x", error: null },
}),
).toMatchObject({ payload: { error: null } });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.background_work.update",
payload: { agentId: "a", items: [ITEM] },
}),
).toMatchObject({ payload: { items: [ITEM] } });
});
+71
View File
@@ -873,6 +873,7 @@ export const AgentSnapshotPayloadSchema = z.object({
updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(),
status: AgentStatusSchema,
backgroundWork: z.object({ count: z.number().int().nonnegative() }).optional(),
activeTurn: AgentActiveTurnPayloadSchema.nullable().optional(),
capabilities: AgentCapabilityFlagsSchema,
currentModeId: z.string().nullable(),
@@ -907,6 +908,7 @@ export const AgentListItemPayloadSchema = z.object({
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
status: AgentStatusSchema,
backgroundWork: z.object({ count: z.number().int().nonnegative() }).optional(),
cwd: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
@@ -2001,6 +2003,20 @@ export const ProviderSubagentListRequestMessageSchema = z.object({
requestId: z.string(),
});
export const AgentBackgroundWorkListRequestMessageSchema = z.object({
type: z.literal("agent.background_work.list.request"),
agentId: z.string(),
requestId: z.string(),
});
export const AgentBackgroundWorkStopRequestMessageSchema = z.object({
type: z.literal("agent.background_work.stop.request"),
agentId: z.string(),
itemId: z.string(),
stopToken: z.string(),
requestId: z.string(),
});
export const ProviderSubagentTimelineRequestMessageSchema = z.object({
type: z.literal("agent.provider_subagents.timeline.get.request"),
parentAgentId: z.string(),
@@ -3287,6 +3303,7 @@ export const SessionEventSubscriptionSchema = z.enum([
"script_status_update",
"workspace_setup_progress",
"agent.provider_subagents.update",
"agent.background_work.update",
"terminal_attention_required",
"status.server_info",
"status.daemon_config_changed",
@@ -3436,6 +3453,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
AgentTimelineSearchRequestMessageSchema,
AgentTimelineListPromptsRequestMessageSchema,
ProviderSubagentListRequestMessageSchema,
AgentBackgroundWorkListRequestMessageSchema,
AgentBackgroundWorkStopRequestMessageSchema,
ProviderSubagentTimelineRequestMessageSchema,
SetAgentTimelineSubscriptionRequestMessageSchema,
AgentForkContextRequestMessageSchema,
@@ -3863,6 +3882,10 @@ export const ServerInfoStatusPayloadSchema = z
agentForkContextCursor: z.boolean().optional(),
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: z.boolean().optional(),
// COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06.
agentBackgroundWork: z.boolean().optional(),
// COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03.
agentBackgroundWorkStop: z.boolean().optional(),
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field.
projectedSubagentTimeline: z.boolean().optional(),
wholeTurnTimeline: z.boolean().optional(),
@@ -4953,6 +4976,51 @@ export const ProviderSubagentListResponseMessageSchema = z.object({
}),
});
export const AgentBackgroundWorkItemSchema = z.object({
id: z.string(),
// Open string: known kinds include "shell" and "monitor".
// Clients treat unknown values as "other".
kind: z.string(),
description: z.string().nullable(),
command: z.string().optional(),
startedAt: z.string(),
canStop: z.boolean().optional(),
stopToken: z.string().optional(),
});
export type AgentBackgroundWorkItemPayload = z.infer<typeof AgentBackgroundWorkItemSchema>;
export const AgentBackgroundWorkListResponseMessageSchema = z.object({
type: z.literal("agent.background_work.list.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
items: z.array(AgentBackgroundWorkItemSchema),
error: z.string().nullable(),
}),
});
export const AgentBackgroundWorkStopResponseMessageSchema = z.object({
type: z.literal("agent.background_work.stop.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
itemId: z.string(),
error: z.string().nullable(),
}),
});
// Event push delivered through session.events.set_subscription; it has no .request/.response pair.
// Each update replaces the agent's whole list.
export const AgentBackgroundWorkUpdateMessageSchema = z.object({
type: z.literal("agent.background_work.update"),
payload: z.object({
subscriptionId: z.string().optional(),
agentId: z.string(),
items: z.array(AgentBackgroundWorkItemSchema),
}),
});
export const ProviderSubagentTimelineResponseMessageSchema = z.object({
type: z.literal("agent.provider_subagents.timeline.get.response"),
payload: z.object({
@@ -7212,6 +7280,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProviderSubagentListResponseMessageSchema,
ProviderSubagentTimelineResponseMessageSchema,
ProviderSubagentUpdateMessageSchema,
AgentBackgroundWorkListResponseMessageSchema,
AgentBackgroundWorkStopResponseMessageSchema,
AgentBackgroundWorkUpdateMessageSchema,
SetAgentTimelineSubscriptionResponseMessageSchema,
AgentAttentionRequiredMessageSchema,
AgentForkContextResponseMessageSchema,
@@ -13075,3 +13075,172 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
expect(h.commands).toEqual(["/maintenance"]);
expect(h.prompts).toEqual([]);
});
test("folds provider background work and clears it when the agent closes", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-background-work-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let activeSession: TestAgentSession | null = null;
class BackgroundWorkClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
activeSession = new TestAgentSession(config);
return activeSession;
}
}
const manager = new AgentManager({
clients: { codex: new BackgroundWorkClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000201",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false });
activeSession?.pushEvent({
type: "background_work",
provider: "codex",
items: [{ id: "bash-1", kind: "shell", description: "sleep 20" }],
});
await vi.waitFor(() =>
expect(manager.listAgentBackgroundWork(snapshot.id)).toEqual([
{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: expect.any(String) },
]),
);
expect(events).toContainEqual({
type: "background_work",
agentId: snapshot.id,
items: [expect.objectContaining({ id: "bash-1" })],
});
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
expect(manager.getAgentBackgroundWorkSummary(snapshot.id)).toEqual({ count: 1 });
expect(
events.some((event) => event.type === "agent_stream" && event.event.type === "background_work"),
).toBe(false);
await manager.closeAgent(snapshot.id);
expect(events).toContainEqual({ type: "background_work", agentId: snapshot.id, items: [] });
expect(manager.getAgentBackgroundWorkSummary(snapshot.id)).toBeUndefined();
});
test("does not deliver an internal agent's background work to global subscribers", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-background-work-internal-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let activeSession: TestAgentSession | null = null;
class BackgroundWorkClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
activeSession = new TestAgentSession(config);
return activeSession;
}
}
const manager = new AgentManager({
clients: { codex: new BackgroundWorkClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000202",
});
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { replayState: false });
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, internal: true },
undefined,
{ workspaceId: undefined },
);
const agentEvents: AgentManagerEvent[] = [];
manager.subscribe((event) => agentEvents.push(event), {
agentId: agent.id,
replayState: false,
});
activeSession?.pushEvent({
type: "background_work",
provider: "codex",
items: [{ id: "bash-1", kind: "shell", description: null }],
});
// The per-agent subscriber proves the event was emitted before the global one is checked.
await vi.waitFor(() =>
expect(agentEvents).toContainEqual(
expect.objectContaining({ type: "background_work", agentId: agent.id }),
),
);
expect(events.some((event) => event.type === "background_work")).toBe(false);
await expect(
manager.stopAgentBackgroundWork({ agentId: agent.id, itemId: "bash-1", stopToken: "token" }),
).rejects.toThrow("Unknown agent");
});
test("stops only a currently owned public background item with its current token", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-background-stop-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const stopped: string[] = [];
let activeSession: TestAgentSession | null = null;
class StoppableSession extends TestAgentSession {
async stopBackgroundWork(id: string) {
stopped.push(id);
}
}
class BackgroundWorkClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
activeSession = new StoppableSession(config);
return activeSession;
}
}
const manager = new AgentManager({
clients: { codex: new BackgroundWorkClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000204",
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
activeSession?.pushEvent({
type: "background_work",
provider: "codex",
items: [
{ id: "one", kind: "shell", description: null, canStop: true },
{ id: "two", kind: "other", description: null },
],
});
await vi.waitFor(() => expect(manager.listAgentBackgroundWork(agent.id)).toHaveLength(2));
const [one] = manager.listAgentBackgroundWork(agent.id);
await expect(
manager.stopAgentBackgroundWork({ agentId: agent.id, itemId: "one", stopToken: "stale-token" }),
).rejects.toThrow();
await expect(
manager.stopAgentBackgroundWork({
agentId: agent.id,
itemId: "two",
stopToken: one.stopToken!,
}),
).rejects.toThrow();
await manager.stopAgentBackgroundWork({
agentId: agent.id,
itemId: "one",
stopToken: one.stopToken!,
});
expect(stopped).toEqual(["one"]);
activeSession?.pushEvent({
type: "background_work",
provider: "codex",
items: [{ id: "one", kind: "shell", description: null }],
});
activeSession?.pushEvent({
type: "background_work",
provider: "codex",
items: [{ id: "one", kind: "shell", description: null, canStop: true }],
});
await expect(
manager.stopAgentBackgroundWork({
agentId: agent.id,
itemId: "one",
stopToken: one.stopToken!,
}),
).rejects.toThrow();
expect(stopped).toEqual(["one"]);
await manager.closeAgent(agent.id);
});
@@ -98,6 +98,7 @@ import {
type ProviderSubagentDescriptor,
type ProviderSubagentStoreEvent,
} from "./provider-subagents/store.js";
import { AgentBackgroundWorkStore, type AgentBackgroundWorkItem } from "./background-work/index.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { extractAttention } from "../persistence-hooks.js";
@@ -171,6 +172,12 @@ async function assertUsableWorkingDirectory(cwd: string): Promise<void> {
}
}
interface StopAgentBackgroundWorkOptions {
agentId: string;
itemId: string;
stopToken: string;
}
async function resolveResumePurpose(
record: StoredAgentRecord | null,
requested: AgentResumePurpose | undefined,
@@ -255,6 +262,7 @@ export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| { type: "background_work"; agentId: string; items: AgentBackgroundWorkItem[] }
| { type: "timeline_replacement"; agentId: string; epoch: string }
| {
type: "agent_stream";
@@ -794,6 +802,7 @@ export class AgentManager {
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore: InMemoryAgentTimelineStore;
private readonly providerSubagents = new ProviderSubagentStore();
private readonly backgroundWork = new AgentBackgroundWorkStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
@@ -1354,6 +1363,38 @@ export class AgentManager {
return this.providerSubagents.list(parentAgentId);
}
listAgentBackgroundWork(agentId: string): AgentBackgroundWorkItem[] {
this.requirePublicAgent(agentId);
return this.backgroundWork.list(agentId);
}
async stopAgentBackgroundWork({
agentId,
itemId,
stopToken,
}: StopAgentBackgroundWorkOptions): Promise<void> {
this.requirePublicAgent(agentId);
// Inventory events share the session queue; drain events already received before
// authorizing the token, including a reconnect's ownership revocation.
await this.sessionEventTails.get(agentId)?.catch(() => undefined);
const agent = this.requirePublicAgent(agentId);
if (!agent.session || !agent.session.stopBackgroundWork)
throw new Error("Background work cannot be stopped");
const item = this.backgroundWork.list(agentId).find((entry) => entry.id === itemId);
if (!item?.canStop || item.stopToken !== stopToken)
throw new Error("Background work is no longer available to stop");
await agent.session.stopBackgroundWork(itemId);
}
getAgentBackgroundWorkSummary(agentId: string): { count: number } | undefined {
return this.backgroundWork.summary(agentId);
}
private clearBackgroundWork(agentId: string): void {
const items = this.backgroundWork.clear(agentId);
if (items) this.dispatch({ type: "background_work", agentId, items });
}
listProviderSubagentActivity(): ProviderSubagentDescriptor[] {
const publicParentIds = new Set(
Array.from(this.agents.values())
@@ -1740,6 +1781,7 @@ export class AgentManager {
await this.closeReloadedSession(existing.session, agentId);
await this.drainSessionEvents(agentId);
this.cancelRunningProviderSubagents(agentId);
this.clearBackgroundWork(agentId);
closedExisting = this.prepareAgentForClosure(existing, "agent reloaded");
await this.persistSnapshot(closedExisting);
this.assertAcceptingAgentRegistrations();
@@ -1990,6 +2032,8 @@ export class AgentManager {
await agent.session.close();
await this.drainSessionEvents(agentId);
this.cancelRunningProviderSubagents(agentId);
// close() drops the session's subscribers before the provider could report an empty list.
this.clearBackgroundWork(agentId);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
let persistError: unknown;
@@ -4182,6 +4226,7 @@ export class AgentManager {
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
this.clearBackgroundWork(agentId);
}
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
@@ -4291,6 +4336,14 @@ export class AgentManager {
this.dispatch({ type: "provider_subagent", event: update });
return;
}
if (event.type === "background_work") {
const items =
event.items === null
? this.backgroundWork.clear(agent.id)
: this.backgroundWork.apply(agent.id, event.items);
if (items) this.dispatch({ type: "background_work", agentId: agent.id, items });
return;
}
const turnId = getAgentStreamEventTurnId(event);
const matchingWaiters = this.runs.getMatchingWaiters(agent, turnId);
this.logger.trace(
@@ -5510,6 +5563,13 @@ export class AgentManager {
) {
continue;
}
if (
subscriber.agentId &&
event.type === "background_work" &&
subscriber.agentId !== event.agentId
) {
continue;
}
if (
subscriber.agentId &&
event.type === "agent_state" &&
@@ -5543,6 +5603,7 @@ export class AgentManager {
if (event.type === "agent_state") return event.agent.internal === true;
if (event.type === "agent_removed") return event.internal;
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
if (event.type === "background_work") return this.agents.get(event.agentId)?.internal === true;
if (event.type !== "provider_subagent") return false;
const parentAgentId =
event.event.type === "upsert"
@@ -4,6 +4,7 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-manager.js";
import {
buildStoredAgentPayload,
toAgentPayload,
toAgentListItemPayload,
toRecentProviderSessionDescriptorPayload,
toStoredAgentRecord,
type ManagedAgent,
@@ -539,3 +540,11 @@ describe("toRecentProviderSessionDescriptorPayload", () => {
});
});
});
it("omits unknown background work and preserves known summary in list items", () => {
const snapshot = toAgentPayload(createManagedAgent());
expect(toAgentListItemPayload(snapshot)).not.toHaveProperty("backgroundWork");
expect(toAgentListItemPayload({ ...snapshot, backgroundWork: { count: 2 } })).toMatchObject({
backgroundWork: { count: 2 },
});
});
@@ -287,6 +287,7 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
thinkingOptionId: agent.thinkingOptionId,
effectiveThinkingOptionId: agent.effectiveThinkingOptionId,
status: agent.status,
...(agent.backgroundWork ? { backgroundWork: agent.backgroundWork } : {}),
cwd: agent.cwd,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
@@ -468,6 +468,12 @@ export type AgentStreamEvent =
type: "provider_subagent";
provider: AgentProvider;
event: import("./provider-subagents/store.js").ProviderSubagentInputEvent;
}
| {
/** Full replacement of the agent's live background work (shells, monitors…). */
type: "background_work";
provider: AgentProvider;
items: import("./background-work/index.js").AgentBackgroundWorkInput[] | null;
};
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
@@ -705,6 +711,8 @@ export interface AgentSession {
* still uncertain.
*/
interrupt(): Promise<void>;
/** Stop one currently owned background item and wait for provider confirmation. */
stopBackgroundWork?(id: string): Promise<void>;
/** True only when releasing this runtime loses no background work or pending replies.
* An optional additional veto: missing methods use daemon baseline eligibility;
* implemented checks that reject or return false keep the runtime resident. */
@@ -0,0 +1,133 @@
import { describe, expect, test } from "vitest";
import { AgentBackgroundWorkStore } from "./index.js";
const T0 = "2026-10-06T10:00:00.000Z";
const T1 = "2026-10-06T10:05:00.000Z";
describe("AgentBackgroundWorkStore", () => {
test("stamps new items and keeps their start time across replacements", () => {
const store = new AgentBackgroundWorkStore();
expect(
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0),
).toEqual([{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 }]);
expect(
store.apply(
"agent-a",
[
{ id: "bash-1", kind: "shell", description: "sleep 20" },
{ id: "task-1", kind: "other", description: null },
],
T1,
),
).toEqual([
{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 },
{ id: "task-1", kind: "other", description: null, startedAt: T1 },
]);
});
test("returns null when the replacement changes nothing", () => {
const store = new AgentBackgroundWorkStore();
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0);
expect(
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T1),
).toBeNull();
expect(store.apply("agent-b", [], T1)).toEqual([]);
expect(store.apply("agent-b", [], T1)).toBeNull();
});
test("distinguishes an unknown inventory from an observed empty inventory", () => {
const store = new AgentBackgroundWorkStore();
expect(store.summary("agent-a")).toBeUndefined();
store.apply("agent-a", []);
expect(store.summary("agent-a")).toEqual({ count: 0 });
store.clear("agent-a");
expect(store.summary("agent-a")).toBeUndefined();
});
test("keeps the first occurrence of a duplicated id", () => {
const store = new AgentBackgroundWorkStore();
expect(
store.apply(
"agent-a",
[
{ id: "bash-1", kind: "shell", description: "first" },
{ id: "bash-1", kind: "shell", description: "second" },
],
T0,
),
).toEqual([{ id: "bash-1", kind: "shell", description: "first", startedAt: T0 }]);
});
test("scopes lists per agent and clears one agent", () => {
const store = new AgentBackgroundWorkStore();
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0);
store.apply("agent-b", [{ id: "bash-2", kind: "shell", description: null }], T0);
expect(store.clear("agent-a")).toEqual([]);
expect(store.clear("agent-a")).toBeNull();
expect(store.list("agent-a")).toEqual([]);
expect(store.list("agent-b")).toHaveLength(1);
});
test("keeps only the wire fields of each input", () => {
const store = new AgentBackgroundWorkStore();
const input = { id: "bash-1", kind: "shell", description: null, pid: 4242 };
expect(store.apply("agent-a", [input], T0)).toEqual([
{ id: "bash-1", kind: "shell", description: null, startedAt: T0 },
]);
});
test("list returns a copy", () => {
const store = new AgentBackgroundWorkStore();
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0);
store.list("agent-a").pop();
expect(store.list("agent-a")).toHaveLength(1);
});
});
test("stop tokens follow a live item and change when an id is reused", () => {
const store = new AgentBackgroundWorkStore();
const input = { id: "1", kind: "shell", description: null, canStop: true };
const first = store.apply("agent", [input], T0)![0];
expect(first.stopToken).toBeTruthy();
expect(store.apply("agent", [input], T1)).toBeNull();
store.apply("agent", []);
const second = store.apply("agent", [input], T0)![0];
expect(second.stopToken).not.toBe(first.stopToken);
});
test("revokes a stop token while inventory ownership is being re-established", () => {
const store = new AgentBackgroundWorkStore();
const item = { id: "1", kind: "shell", description: null, canStop: true };
const first = store.apply("agent", [item])![0];
const stale = store.apply("agent", [{ ...item, canStop: false }])![0];
expect(stale.stopToken).toBeUndefined();
expect(store.apply("agent", [item])![0].stopToken).not.toBe(first.stopToken);
});
test("publishes command changes without replacing a live item's identity", () => {
const store = new AgentBackgroundWorkStore();
const item = {
id: "1",
kind: "monitor",
description: "Watch build",
command: "tail -f build.log",
canStop: true,
};
const first = store.apply("agent", [item], T0)![0];
expect(first.command).toBe("tail -f build.log");
const changed = store.apply("agent", [{ ...item, command: "tail -f other.log" }], T1)![0];
expect(changed).toMatchObject({
command: "tail -f other.log",
startedAt: T0,
stopToken: first.stopToken,
});
});
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
export interface AgentBackgroundWorkInput {
id: string;
/** Provider-neutral kind; consumers treat unknown kinds as other work. */
kind: string;
description: string | null;
command?: string;
canStop?: boolean;
}
export interface AgentBackgroundWorkItem extends AgentBackgroundWorkInput {
startedAt: string;
stopToken?: string;
}
/**
* Live background work per agent. Providers report the whole list on every change, so `apply`
* replaces it. `startedAt` is stamped the first time an id is seen and kept while it stays listed.
* Nothing is persisted; providers determine when their live inventory is no longer available.
*/
export class AgentBackgroundWorkStore {
private readonly items = new Map<string, AgentBackgroundWorkItem[]>();
apply(
agentId: string,
inputs: readonly AgentBackgroundWorkInput[],
timestamp: string = new Date().toISOString(),
): AgentBackgroundWorkItem[] | null {
const known = this.items.has(agentId);
const previous = this.items.get(agentId) ?? [];
const previousById = new Map(previous.map((item) => [item.id, item]));
const seen = new Set<string>();
const next: AgentBackgroundWorkItem[] = [];
for (const input of inputs) {
if (seen.has(input.id)) continue;
seen.add(input.id);
next.push({
id: input.id,
kind: input.kind,
description: input.description,
...(input.command ? { command: input.command } : {}),
...(input.canStop
? { canStop: true, stopToken: previousById.get(input.id)?.stopToken ?? randomUUID() }
: {}),
startedAt: previousById.get(input.id)?.startedAt ?? timestamp,
});
}
if (known && sameItems(previous, next)) return null;
this.items.set(agentId, next);
return [...next];
}
list(agentId: string): AgentBackgroundWorkItem[] {
return [...(this.items.get(agentId) ?? [])];
}
summary(agentId: string): { count: number } | undefined {
const items = this.items.get(agentId);
return items ? { count: items.length } : undefined;
}
clear(agentId: string): AgentBackgroundWorkItem[] | null {
if (!this.items.delete(agentId)) return null;
return [];
}
}
function sameItems(
left: readonly AgentBackgroundWorkItem[],
right: readonly AgentBackgroundWorkItem[],
): boolean {
return (
left.length === right.length &&
left.every((item, index) => {
const other = right[index];
return (
item.id === other.id &&
item.kind === other.kind &&
item.description === other.description &&
item.command === other.command &&
item.canStop === other.canStop &&
item.stopToken === other.stopToken &&
item.startedAt === other.startedAt
);
})
);
}
@@ -468,6 +468,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(),
stopBackgroundWork: inner.stopBackgroundWork?.bind(inner),
close: () => inner.close(),
canStopRuntime: inner.canStopRuntime?.bind(inner),
listCommands: inner.listCommands?.bind(inner),
@@ -0,0 +1,298 @@
import { afterEach, describe, expect, test } from "vitest";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { SessionConfigOption, SessionUpdate } from "@agentclientprotocol/sdk";
import { ACPAgentClient, type SessionStateResponse } from "./acp-agent.js";
import { CopilotACPAgentClient, COPILOT_ALLOW_ALL_MODE_ID } from "./copilot-acp-agent.js";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const modes = {
currentModeId: "accept_edits",
availableModes: [
{ id: "default", name: "Default" },
{ id: "accept_edits", name: "Accept Edits" },
{ id: "dont_ask", name: "Don't Ask" },
],
};
function editOptions(currentValue = "workspace_session"): SessionConfigOption[] {
return [
{
id: "edit_approval_policy",
name: "Edits",
category: "mode",
type: "select",
currentValue,
options: [
{ value: "ask", name: "Default" },
{ value: "workspace_session", name: "Accept Edits" },
{ value: "session", name: "Don't Ask" },
],
},
];
}
// A controlled ACP process, speaking the same stdio protocol as a provider.
// Notifications precede session/new's response to exercise queued initialization updates.
const modePeerScript = String.raw`
import { createInterface } from "node:readline";
import { readFileSync, appendFileSync } from "node:fs";
const scenario = JSON.parse(readFileSync(process.argv[2], "utf8"));
let configOptions = scenario.state.configOptions;
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
const reply = (result) => send({jsonrpc: "2.0", id: request.id, result});
const reject = (message) => send({jsonrpc: "2.0", id: request.id, error: {code: -32602, message}});
switch (request.method) {
case "initialize":
reply({protocolVersion: 1, agentCapabilities: {}});
break;
case "session/new":
for (const update of scenario.updates) {
send({jsonrpc: "2.0", method: "session/update", params: {sessionId: "mode-session", update}});
}
reply({sessionId: "mode-session", ...scenario.state});
break;
case "session/set_mode":
appendFileSync(process.argv[3], JSON.stringify(request) + "\n");
if (request.params.modeId === scenario.rejectedMode) {
reject("mode denied");
} else if (!scenario.state.modes?.availableModes.some(mode => mode.id === request.params.modeId)) {
reject("invalid_params");
} else {
reply({});
}
break;
case "session/set_config_option":
appendFileSync(process.argv[3], JSON.stringify(request) + "\n");
configOptions = configOptions.map(option => option.id === request.params.configId
? {...option, currentValue: request.params.value} : option);
reply({configOptions});
break;
default:
reject("Unexpected request: " + request.method);
}
});
`;
interface ModeCall {
method: string;
params: { sessionId: string; modeId?: string; configId?: string; value?: string };
}
let session: AgentSession | undefined;
let directory: string | undefined;
afterEach(async () => {
try {
await session?.close();
} finally {
session = undefined;
if (directory) await rm(directory, { recursive: true, force: true });
directory = undefined;
}
});
async function fixture(
options: {
state?: Omit<SessionStateResponse, "sessionId">;
updates?: SessionUpdate[];
rejectedMode?: string;
startupMode?: string;
copilot?: boolean;
} = {},
) {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
directory = await mkdtemp(path.join(artifacts, "acp-mode-"));
const script = path.join(directory, "peer.mjs");
const scenario = path.join(directory, "scenario.json");
const calls = path.join(directory, "calls.jsonl");
await writeFile(script, modePeerScript);
await writeFile(calls, "");
await writeFile(
scenario,
JSON.stringify({
state: options.state ?? { modes, configOptions: editOptions() },
updates: options.updates ?? [
{ sessionUpdate: "config_option_update", configOptions: editOptions() },
],
rejectedMode: options.rejectedMode,
}),
);
const command: [string, ...string[]] = [process.execPath, script, scenario, calls];
const client = options.copilot
? new CopilotACPAgentClient({
logger: createTestLogger(),
runtimeSettings: { command: { mode: "replace", argv: command } },
})
: new ACPAgentClient({
provider: "mode-peer",
logger: createTestLogger(),
defaultCommand: command,
});
session = await client.createSession({
provider: options.copilot ? "copilot" : "mode-peer",
cwd: directory,
modeId: options.startupMode,
});
return {
session,
async calls(): Promise<ModeCall[]> {
const lines = (await readFile(calls, "utf8")).trim();
return lines
? lines.split("\n").map((line) => {
const { method, params } = JSON.parse(line) as ModeCall;
return { method, params };
})
: [];
},
};
}
const modeCall = (modeId: string) => ({
method: "session/set_mode",
params: { sessionId: "mode-session", modeId },
});
const configCall = (configId: string, value: string) => ({
method: "session/set_config_option",
params: { sessionId: "mode-session", configId, value },
});
describe("ACP mode and config-value namespaces over stdio", () => {
test("selects Don't Ask from the advertised catalogue after a queued config update", async () => {
const peer = await fixture();
const available = await peer.session.getAvailableModes();
const dontAsk = available.find((mode) => mode.label === "Don't Ask");
expect(dontAsk).toBeDefined();
await peer.session.setMode(dontAsk!.id);
expect(await peer.calls()).toEqual([modeCall("dont_ask")]);
expect(await peer.session.getCurrentMode()).toBe("dont_ask");
expect(available.map((mode) => mode.id)).toEqual(["default", "accept_edits", "dont_ask"]);
});
test("keeps a pushed current mode through separate-policy and empty config updates", async () => {
const peer = await fixture({
updates: [
{ sessionUpdate: "current_mode_update", currentModeId: "dont_ask" },
{ sessionUpdate: "config_option_update", configOptions: editOptions("session") },
{ sessionUpdate: "config_option_update", configOptions: [] },
],
});
expect(await peer.session.getCurrentMode()).toBe("dont_ask");
expect((await peer.session.getAvailableModes()).map((mode) => mode.id)).toEqual([
"default",
"accept_edits",
"dont_ask",
]);
expect(await peer.calls()).toEqual([]);
});
test("ignores policy values outside the advertised session modes without an RPC", async () => {
const peer = await fixture();
await peer.session.setMode("session");
expect(await peer.calls()).toEqual([]);
expect(await peer.session.getCurrentMode()).toBe("accept_edits");
});
test("retains confirmed state after rejection without falling back to a config write", async () => {
const peer = await fixture({ rejectedMode: "dont_ask", updates: [] });
await expect(peer.session.setMode("dont_ask")).rejects.toThrow("mode denied");
expect(await peer.session.getCurrentMode()).toBe("accept_edits");
expect(await peer.calls()).toEqual([modeCall("dont_ask")]);
});
test("selects a config-only mode through its owning config option", async () => {
const peer = await fixture({ state: { configOptions: editOptions() } });
await peer.session.setMode("session");
expect(await peer.calls()).toEqual([configCall("edit_approval_policy", "session")]);
expect(await peer.session.getCurrentMode()).toBe("session");
});
test.each([true, false])("routes startup overrides with session modes: %s", async (withModes) => {
const peer = await fixture({
state: { modes: withModes ? modes : undefined, configOptions: editOptions() },
startupMode: withModes ? "dont_ask" : "session",
});
expect(await peer.calls()).toEqual([
withModes ? modeCall("dont_ask") : configCall("edit_approval_policy", "session"),
]);
expect(await peer.session.getCurrentMode()).toBe(withModes ? "dont_ask" : "session");
});
});
const agentMode = "https://agentclientprotocol.com/protocol/session-modes#agent";
const planMode = "https://agentclientprotocol.com/protocol/session-modes#plan";
function copilotConfig(allowAll: string): SessionConfigOption[] {
return [
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: agentMode,
options: [
{ value: agentMode, name: "Agent" },
{ value: planMode, name: "Plan" },
],
},
{
id: "allow_all",
name: "Allow All",
type: "select",
currentValue: allowAll,
options: [
{ value: "on", name: "On" },
{ value: "off", name: "Off" },
],
},
];
}
function copilotFixture(initial: string, pushed: string) {
return fixture({
copilot: true,
state: {
modes: {
currentModeId: agentMode,
availableModes: [
{ id: agentMode, name: "Agent" },
{ id: planMode, name: "Plan" },
],
},
configOptions: copilotConfig(initial),
},
updates: [{ sessionUpdate: "config_option_update", configOptions: copilotConfig(pushed) }],
});
}
describe("Copilot Allow All through the public client", () => {
test("adopts an Allow All notification and disables it before switching modes", async () => {
const peer = await copilotFixture("off", "on");
expect(await peer.session.getCurrentMode()).toBe(COPILOT_ALLOW_ALL_MODE_ID);
await peer.session.setMode(planMode);
expect(await peer.calls()).toEqual([configCall("allow_all", "off"), modeCall(planMode)]);
expect(await peer.session.getCurrentMode()).toBe(planMode);
});
test("adopts an Allow All off notification after initializing with it enabled", async () => {
const peer = await copilotFixture("on", "off");
expect(await peer.session.getCurrentMode()).toBe(agentMode);
expect(await peer.calls()).toEqual([]);
});
test("selects Allow All and switches away in provider RPC order", async () => {
const peer = await copilotFixture("off", "off");
await peer.session.setMode(COPILOT_ALLOW_ALL_MODE_ID);
expect(await peer.session.getCurrentMode()).toBe(COPILOT_ALLOW_ALL_MODE_ID);
await peer.session.setMode(planMode);
expect(await peer.calls()).toEqual([
modeCall(agentMode),
configCall("allow_all", "on"),
configCall("allow_all", "off"),
modeCall(planMode),
]);
expect(await peer.session.getCurrentMode()).toBe(planMode);
});
});

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