mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47d3910943 | ||
|
|
2095efb07c | ||
|
|
28c01ea1ce | ||
|
|
a449271d57 | ||
|
|
aac228e0a8 | ||
|
|
0e40022519 | ||
|
|
751dc82e6c | ||
|
|
daf26892d3 | ||
|
|
c5cf6cbb4c | ||
|
|
78a089a14e | ||
|
|
ec3ae09926 | ||
|
|
9f68af0274 | ||
|
|
79f2db8d56 | ||
|
|
1efcb31a6c | ||
|
|
ea30d2616a | ||
|
|
f21699a37a | ||
|
|
a9bce9a18d | ||
|
|
73448d483e | ||
|
|
161076a853 | ||
|
|
14845d3ff3 | ||
|
|
616f5e155c | ||
|
|
471db4bdb5 | ||
|
|
68fc16b855 | ||
|
|
ae157dbfb6 | ||
|
|
dc51a3b6a9 | ||
|
|
77798b2e46 |
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.3 - 2026-11-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp))
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.3 - 2026-11-06
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp))
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,7 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice.
|
||||
- **A history row re-renders only when its item or layout item identity changes.** Every
|
||||
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
|
||||
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
||||
branch on its current route.
|
||||
|
||||
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
|
||||
Pass only `serverId` and `workspaceId` to restore the workspace’s selected tab.
|
||||
When the action names a specific tab, pass it as `target`; that explicit choice
|
||||
is authoritative. Callers should not choose between separate route and tab
|
||||
navigation APIs.
|
||||
|
||||
@@ -570,6 +570,13 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
|
||||
|
||||
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
## Replace sidebar content
|
||||
|
||||
The sidebar view slot is global across hosts, while header and footer contributions retain their
|
||||
host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns
|
||||
its layout. This keeps plugin failures from removing navigation or recovery controls. The
|
||||
[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
Sidebar header and footer items are plugin components, not descriptors. The
|
||||
|
||||
@@ -15,17 +15,20 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. It also restores SGR mouse encoding: the addon preserves mouse tracking but omits its encoding, so switching away and back otherwise sends legacy wheel reports that Copilot CLI cannot use. These missing modes require xterm's internal services because its public modes API omits them. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
|
||||
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/server": "0.15.2",
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/server": "0.15.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39890,11 +39890,11 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40149,7 +40149,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
@@ -40233,8 +40233,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40244,8 +40244,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40267,7 +40267,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/highlight": "0.15.2",
|
||||
"@getpaseo/plugin": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/highlight": "0.15.3",
|
||||
"@getpaseo/plugin": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41474,7 +41474,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43084,7 +43084,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test as baseTest } from "../support/fixtures";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
|
||||
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
|
||||
// within a few milliseconds of each other. These tests hold all three, let the running
|
||||
// turn paint, then release them together and sample every painted frame.
|
||||
|
||||
interface Box {
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TurnEndFrame {
|
||||
at: number;
|
||||
running: boolean;
|
||||
footer: Box | null;
|
||||
userRow: Box | null;
|
||||
replyVisible: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
|
||||
}
|
||||
}
|
||||
|
||||
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
|
||||
agent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-end-",
|
||||
title: "Turn end",
|
||||
initialPrompt: "emit 4 tool agent stream updates",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await provide(agent);
|
||||
await agent.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function recordTurnEndFrames(
|
||||
page: Page,
|
||||
input: { prompt: string; reply: string | null },
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ prompt, reply }) => {
|
||||
const state = { active: true, frames: [] as TurnEndFrame[] };
|
||||
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
|
||||
const boxOf = (element: Element | null | undefined): Box | null => {
|
||||
if (!element || !isVisible(element)) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
|
||||
};
|
||||
const sample = () => {
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => isVisible(candidate));
|
||||
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.map(boxOf)
|
||||
.filter((box): box is Box => box !== null)
|
||||
.sort((left, right) => left.top - right.top);
|
||||
const userRow = Array.from(
|
||||
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
|
||||
).find((candidate) => candidate.textContent?.includes(prompt));
|
||||
const replyRow =
|
||||
reply === null
|
||||
? undefined
|
||||
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
|
||||
(candidate) => candidate.textContent?.includes(reply),
|
||||
);
|
||||
state.frames.push({
|
||||
at: performance.now(),
|
||||
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
|
||||
footer: footers.at(-1) ?? null,
|
||||
userRow: boxOf(userRow),
|
||||
replyVisible: boxOf(replyRow) !== null,
|
||||
});
|
||||
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
|
||||
};
|
||||
window.__turnEndFrames = state;
|
||||
requestAnimationFrame(() => setTimeout(sample, 0));
|
||||
}, input);
|
||||
}
|
||||
|
||||
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
|
||||
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
|
||||
.toBeGreaterThanOrEqual(until);
|
||||
}
|
||||
|
||||
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__turnEndFrames;
|
||||
if (!state) throw new Error("Turn end frames were not recorded");
|
||||
state.active = false;
|
||||
return state.frames;
|
||||
});
|
||||
}
|
||||
|
||||
async function recordTurnEnd(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
input: { prompt: string; reply: string | null },
|
||||
): Promise<TurnEndFrame[]> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectAgentIdle(page);
|
||||
|
||||
if (input.reply === null) {
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
} else {
|
||||
gate.holdNextAgentStreamItem("assistant_message");
|
||||
}
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await recordTurnEndFrames(page, input);
|
||||
await submitMessage(page, input.prompt);
|
||||
await Promise.all([
|
||||
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await recordPaintsFor(page, 200);
|
||||
|
||||
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
|
||||
gate.releaseHeldAgentStreamEvent("turn_completed");
|
||||
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
await expectAgentIdle(page);
|
||||
await recordPaintsFor(page, 300);
|
||||
return stopRecording(page);
|
||||
}
|
||||
|
||||
function describeFrames(frames: TurnEndFrame[]): string {
|
||||
return frames
|
||||
.map(
|
||||
(frame, index) =>
|
||||
`${index}: ${frame.running ? "running " : "complete"} footer=${
|
||||
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
|
||||
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
|
||||
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
|
||||
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
|
||||
const record = `frames:\n${describeFrames(frames)}`;
|
||||
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
|
||||
expect(firstComplete, record).toBeGreaterThan(firstRunning);
|
||||
|
||||
const transition = frames.slice(firstRunning);
|
||||
const runningHeight = transition[0].footer?.height;
|
||||
expect(
|
||||
transition.map((frame) => frame.footer?.height ?? null),
|
||||
`the footer must stay painted at one height\n${record}`,
|
||||
).toEqual(transition.map(() => runningHeight));
|
||||
if (input.reply !== null) {
|
||||
expect(
|
||||
frames.slice(firstComplete).every((frame) => frame.replyVisible),
|
||||
`a completed turn must show its reply in the same frame\n${record}`,
|
||||
).toBe(true);
|
||||
}
|
||||
// Tool rows land while the turn runs; only the turn end itself must be one step.
|
||||
const ending = frames.slice(firstComplete - 1);
|
||||
const promptMoves = ending
|
||||
.slice(1)
|
||||
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
|
||||
expect(
|
||||
promptMoves.length,
|
||||
`the prompt must move at most once as the turn ends\n${record}`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
test.describe("Turn end", () => {
|
||||
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
|
||||
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
|
||||
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
holdDirectorySuggestionResponses,
|
||||
expectOneLineTruncatedFileResult,
|
||||
expectStableCommandCenterLayout,
|
||||
expectLayoutObservationToCatchPanelShift,
|
||||
failDirectorySuggestionRequests,
|
||||
startCommandCenterLayoutObservation,
|
||||
shiftOutsideCommandCenter,
|
||||
} from "../support/helpers/command-center-file-search";
|
||||
import {
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
@@ -84,7 +86,9 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
||||
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await shiftOutsideCommandCenter(page);
|
||||
await expectStableCommandCenterLayout(page);
|
||||
await expectLayoutObservationToCatchPanelShift(page);
|
||||
|
||||
await row.click();
|
||||
await expectFileTabOpen(page, FILE_PATH);
|
||||
|
||||
@@ -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,5 +1,11 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
pasteRichTextIntoComposer,
|
||||
fillComposerDraft,
|
||||
copyLatestTurnIntoComposer,
|
||||
selectComposerDraftRange,
|
||||
undoComposerEdit,
|
||||
redoComposerEdit,
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
@@ -28,6 +34,7 @@ import {
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
swipeOpenMobileExplorer,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||
import {
|
||||
expectTimelinePromptPositionPreserved,
|
||||
@@ -55,7 +62,9 @@ test("clicking chat text or background focuses the feed for keyboard paging", as
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).offsetY)
|
||||
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("chat-keyboard-paging.png"),
|
||||
});
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
||||
@@ -124,11 +133,15 @@ test("focusing the feed preserves assistant text selection and code copy control
|
||||
});
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const copiedTable =
|
||||
"| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space](<https://example.com/a b>) | **open** |\n| [ASCII](https://example.com/1) | closed |";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-",
|
||||
title: "Composer focus",
|
||||
featureValues: { mockAssistantResponse: copiedTable },
|
||||
});
|
||||
|
||||
const connection = await installDaemonWebSocketGate(page);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
@@ -136,8 +149,62 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
await submitMessage(page, "First message");
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await typeIntoFocusedComposer(page, "Second message");
|
||||
await expectComposerDraft(page, "Second message");
|
||||
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
|
||||
await selectComposerDraftRange(page, 13, 25);
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<a href="https://example.com/issues/39150">this FR</a>',
|
||||
text: "this FR",
|
||||
});
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
|
||||
);
|
||||
await undoComposerEdit(page);
|
||||
await expectComposerDraft(page, "Investigate: replace this. Thanks");
|
||||
await redoComposerEdit(page);
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
|
||||
);
|
||||
await typeIntoFocusedComposer(page, " next");
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await expect(page.getByRole("button", { name: "Copy turn", exact: true })).toBeVisible();
|
||||
await connection.drop();
|
||||
await connection.waitForBlockedConnection();
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<a href="https://example.com/offline">offline link</a>',
|
||||
text: "offline link",
|
||||
});
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
|
||||
);
|
||||
await fillComposerDraft(page, "");
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
|
||||
text: "Resource\tStatus\nIssue\tclosed",
|
||||
});
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
|
||||
);
|
||||
await fillComposerDraft(page, "");
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<a href="https://example.com/">Issue</a><table></table>',
|
||||
text: "Issue",
|
||||
});
|
||||
await expectComposerDraft(page, "[Issue](https://example.com/)");
|
||||
await copyLatestTurnIntoComposer(page);
|
||||
await expectComposerDraft(page, copiedTable);
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
@@ -168,7 +235,11 @@ async function waitForAnimationFrames(page: Page, count: number): Promise<void>
|
||||
test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
|
||||
// composer there raises the on-screen keyboard over the conversation.
|
||||
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
|
||||
test.use({
|
||||
viewport: { width: 1024, height: 768 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
|
||||
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -192,7 +263,9 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
test("returning to an inactive inline composer does not restore focus on touch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "composer-touch-retained-",
|
||||
});
|
||||
try {
|
||||
const first = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeDiagramFullscreen,
|
||||
expectInlineDiagramFitsViewport,
|
||||
toggleDiagramSourceAndVerifyControls,
|
||||
closeDiagramFullscreenFromOutside,
|
||||
expectCompletedDiagram,
|
||||
expectFullscreenDiagram,
|
||||
@@ -21,6 +23,36 @@ const MERMAID_DIAGRAM = [
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("bounds a tall inline diagram", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const diagram = [
|
||||
"```mermaid",
|
||||
"flowchart TD",
|
||||
...Array.from({ length: 30 }, (_, i) => `N${i} --> N${i + 1}`),
|
||||
"```",
|
||||
].join("\n");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-tall-",
|
||||
title: "Tall Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: diagram,
|
||||
mockStreamingAssistantIntervalMs: 10,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await expectCompletedDiagram(page, ["N0", "N30"]);
|
||||
await expectInlineDiagramFitsViewport(page);
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["N0", "N30"]);
|
||||
await closeDiagramFullscreen(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
@@ -40,6 +72,10 @@ test("opens a Mermaid diagram fullscreen and closes it again", async ({ page })
|
||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("Source toggles preserve the diagram and exclude hidden controls", async () => {
|
||||
await toggleDiagramSourceAndVerifyControls(page);
|
||||
});
|
||||
|
||||
await test.step("The toolbar opens the diagram fullscreen", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withSidebarViews } from "../support/helpers/sidebar-views";
|
||||
|
||||
test("sidebar views replace the content, preserve chrome, and recover to the default", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await withSidebarViews(page, async (views) => {
|
||||
await views.expectDefault();
|
||||
await views.chooseWorkspaces();
|
||||
await views.exerciseExamples();
|
||||
await views.exerciseTerminals();
|
||||
await views.restoreDefault();
|
||||
await views.recoverFromFailure();
|
||||
await views.chooseWorkspaces();
|
||||
await views.disableChosenView();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => {
|
||||
for (;;) {
|
||||
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
|
||||
if (!paste) break;
|
||||
const pasted = JSON.stringify(paste[1]).replace(
|
||||
/\s/g,
|
||||
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
|
||||
);
|
||||
process.stdout.write("PASTED:" + pasted + "\r\n");
|
||||
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
|
||||
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
|
||||
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
|
||||
@@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => {
|
||||
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
// Every bracketed paste body the receiver consumed, in order and joined: what a
|
||||
// shell would read as one command line.
|
||||
async function pastedText(terminalId: string): Promise<string> {
|
||||
const output = await terminalOutput(terminalId);
|
||||
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
|
||||
.map(([, json]) => JSON.parse(json) as string)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Exactly one ASCII space after each quoted path and nothing else between them,
|
||||
// so the paths stay separate shell words.
|
||||
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
|
||||
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
|
||||
}
|
||||
|
||||
test("pastes images from two panes into their own terminals as uploaded host files", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => {
|
||||
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
|
||||
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
|
||||
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
|
||||
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
|
||||
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
|
||||
try {
|
||||
await dropFiles(page, [first]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
|
||||
await dropFiles(page, [second]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
|
||||
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
@@ -344,7 +407,6 @@ test("opening a turn keeps the received message above it clipped on every frame"
|
||||
.getByTestId("user-message")
|
||||
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
|
||||
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
|
||||
await bubble.scrollIntoViewIfNeeded();
|
||||
|
||||
await watchReceivedMessagePreview(page, "Sent by Reviewer");
|
||||
await page
|
||||
|
||||
@@ -112,6 +112,7 @@ test.describe("Workspace archive undo", () => {
|
||||
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, true);
|
||||
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
||||
});
|
||||
|
||||
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -99,6 +99,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
const observedElements = new WeakSet<Element>();
|
||||
const resizeObserver = new ResizeObserver(record);
|
||||
const mutationObserver = new MutationObserver(discover);
|
||||
let performanceObserver: PerformanceObserver | undefined;
|
||||
|
||||
function record(): void {
|
||||
if (!observation.active) return;
|
||||
@@ -132,6 +133,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
if (!observation.active) {
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
performanceObserver?.disconnect();
|
||||
return;
|
||||
}
|
||||
record();
|
||||
@@ -141,9 +143,16 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
try {
|
||||
const performanceObserver = new PerformanceObserver((list) => {
|
||||
performanceObserver = new PerformanceObserver((list) => {
|
||||
const panel = document.querySelector(selectors.panel);
|
||||
for (const entry of list.getEntries()) {
|
||||
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
|
||||
const shift = entry as PerformanceEntry & {
|
||||
value: number;
|
||||
sources: Array<{ node: Node | null }>;
|
||||
};
|
||||
if (shift.sources.some((source) => source.node && panel?.contains(source.node))) {
|
||||
observation.layoutShift += shift.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
performanceObserver.observe({ type: "layout-shift", buffered: false });
|
||||
@@ -174,7 +183,54 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
|
||||
expect(
|
||||
observation.layoutShift,
|
||||
`command center layoutShift: ${JSON.stringify(observation)}`,
|
||||
).toBeLessThanOrEqual(0.001);
|
||||
}
|
||||
|
||||
/** Exercise an unrelated page shift while the real search panel is being observed. */
|
||||
export async function shiftOutsideCommandCenter(page: Page): Promise<void> {
|
||||
await shiftLayoutProbe(page.locator("body"));
|
||||
}
|
||||
|
||||
export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise<void> {
|
||||
await startCommandCenterLayoutObservation(page);
|
||||
await shiftLayoutProbe(page.getByTestId("command-center-panel"));
|
||||
await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift");
|
||||
}
|
||||
|
||||
async function shiftLayoutProbe(parent: Locator): Promise<void> {
|
||||
const recordedShift = await parent.evaluate(async (container) => {
|
||||
const sibling = document.createElement("div");
|
||||
sibling.textContent = "Layout probe";
|
||||
sibling.style.cssText =
|
||||
"position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999";
|
||||
container.append(sibling);
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
const value = await new Promise<number>((resolve) => {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
const shift = entry as PerformanceEntry & {
|
||||
value: number;
|
||||
sources: Array<{ node: Node | null }>;
|
||||
};
|
||||
if (shift.sources.some((source) => source.node === sibling)) {
|
||||
observer.disconnect();
|
||||
resolve(shift.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "layout-shift", buffered: false });
|
||||
sibling.style.left = "400px";
|
||||
});
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
sibling.remove();
|
||||
return value;
|
||||
});
|
||||
expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001);
|
||||
}
|
||||
|
||||
export async function expectOneLineTruncatedFileResult(
|
||||
|
||||
@@ -31,7 +31,9 @@ export async function expectComposerVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||
await expect(composerInput(page)).toBeVisible({
|
||||
timeout: options?.timeout ?? 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectComposerDisabled(page: Page): Promise<void> {
|
||||
@@ -156,7 +158,9 @@ export async function attachImageFromMenu(
|
||||
}
|
||||
|
||||
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachFileFromMenu(
|
||||
@@ -218,18 +222,24 @@ export async function expectGithubAttachmentPill(
|
||||
|
||||
export async function openImageLightbox(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open image attachment" }).first().click();
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeImageLightbox(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-github").click();
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */
|
||||
@@ -283,7 +293,10 @@ export async function startRunningMockAgent(
|
||||
model: opts.model,
|
||||
featureValues: opts.featureValues,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
serverId,
|
||||
workspace.id,
|
||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expectComposerVisible(page);
|
||||
await client.sendAgentMessage(agent.id, opts.prompt);
|
||||
@@ -347,7 +360,11 @@ export async function pasteComposerTestImage(page: Page): Promise<boolean> {
|
||||
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
|
||||
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
input.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}, COMPOSER_TEST_IMAGE_BASE64);
|
||||
@@ -505,7 +522,10 @@ export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
|
||||
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
|
||||
const frames = await page.evaluate(() => {
|
||||
Reflect.get(window, "__stopComposerGeometry")?.();
|
||||
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
|
||||
return Reflect.get(window, "__composerGeometryFrames") as {
|
||||
input: number;
|
||||
root: number;
|
||||
}[];
|
||||
});
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
|
||||
@@ -516,7 +536,10 @@ export async function roundTripComposerTabBeforePaint(
|
||||
page: Page,
|
||||
activeChatTitle: string,
|
||||
): Promise<void> {
|
||||
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
|
||||
const activeTab = page.getByRole("button", {
|
||||
name: activeChatTitle,
|
||||
exact: true,
|
||||
});
|
||||
await expect(activeTab).toHaveAttribute("aria-selected", "true");
|
||||
const result = await activeTab.evaluate((tab) => {
|
||||
const editor = document.activeElement;
|
||||
@@ -556,7 +579,10 @@ export async function roundTripComposerTabBeforePaint(
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
|
||||
observer.observe(tab, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-selected"],
|
||||
});
|
||||
shortcut("BracketLeft");
|
||||
});
|
||||
});
|
||||
@@ -566,3 +592,44 @@ export async function roundTripComposerTabBeforePaint(
|
||||
).toBe(true);
|
||||
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
|
||||
}
|
||||
|
||||
/** Paste both clipboard representations through the browser's normal paste command. */
|
||||
export async function pasteRichTextIntoComposer({
|
||||
page,
|
||||
html,
|
||||
text,
|
||||
}: {
|
||||
page: Page;
|
||||
html: string;
|
||||
text: string;
|
||||
}): Promise<void> {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate(
|
||||
async ({ html: richHtml, text: plainText }) => {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/html": new Blob([richHtml], { type: "text/html" }),
|
||||
"text/plain": new Blob([plainText], { type: "text/plain" }),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
{ html, text },
|
||||
);
|
||||
await composerInput(page).press("ControlOrMeta+v");
|
||||
}
|
||||
|
||||
export async function undoComposerEdit(page: Page): Promise<void> {
|
||||
await composerInput(page).press("ControlOrMeta+z");
|
||||
}
|
||||
export async function redoComposerEdit(page: Page): Promise<void> {
|
||||
await composerInput(page).press("ControlOrMeta+Shift+z");
|
||||
}
|
||||
|
||||
/** Reuse the actual response Copy action and the browser's native paste. */
|
||||
export async function copyLatestTurnIntoComposer(page: Page): Promise<void> {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.getByRole("button", { name: "Copy turn", exact: true }).last().click();
|
||||
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
|
||||
await composerInput(page).fill("");
|
||||
await composerInput(page).press("ControlOrMeta+v");
|
||||
}
|
||||
|
||||
@@ -148,3 +148,59 @@ export async function expectFullscreenDiagram(
|
||||
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Tall diagrams stay within the screen so their controls remain reachable. */
|
||||
export async function expectInlineDiagramFitsViewport(page: Page): Promise<void> {
|
||||
const viewport = page.getByTestId("mermaid-viewport").last();
|
||||
const box = await viewport.boundingBox();
|
||||
expect(box, "the inline diagram has measurable layout").not.toBeNull();
|
||||
const screenHeight = page.viewportSize()!.height;
|
||||
expect(box!.height, "the inline diagram fits within the screen").toBeLessThan(screenHeight);
|
||||
const contentHeight = await diagramSvg(page, "mermaid-viewport").evaluate(
|
||||
(svg) => (svg as SVGSVGElement).viewBox.baseVal.height,
|
||||
);
|
||||
expect(box!.height, "a tall diagram is bounded rather than expanding the chat").toBeLessThan(
|
||||
contentHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/** Exercise pointer toggles and the controls encountered along that same journey. */
|
||||
export async function toggleDiagramSourceAndVerifyControls(page: Page): Promise<void> {
|
||||
const viewport = page.getByTestId("mermaid-viewport").last();
|
||||
const iframe = await viewport.locator("iframe").elementHandle();
|
||||
for (let iteration = 0; iteration < 3; iteration++) {
|
||||
await viewport.hover();
|
||||
await page.getByRole("button", { name: "View source" }).last().click();
|
||||
const viewDiagram = page.getByRole("button", { name: "View diagram" }).last();
|
||||
await viewDiagram.click({ timeout: 10_000 });
|
||||
await expect(viewDiagram).toHaveCount(0);
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
expect(
|
||||
await viewport.locator("iframe").evaluate((node, original) => node === original, iframe),
|
||||
"switching views preserves the rendered diagram",
|
||||
).toBe(true);
|
||||
}
|
||||
await viewport.hover();
|
||||
await page.getByRole("button", { name: "View source" }).last().click();
|
||||
const hiddenFullscreen = viewport.getByRole("button", {
|
||||
name: "Fullscreen",
|
||||
includeHidden: true,
|
||||
});
|
||||
await hiddenFullscreen.focus();
|
||||
await expect(hiddenFullscreen).not.toBeFocused();
|
||||
await page.getByRole("button", { name: "View diagram" }).last().click();
|
||||
await viewport.hover();
|
||||
const fullscreen = viewport.getByRole("button", { name: "Fullscreen", exact: true });
|
||||
const box = await viewport.boundingBox();
|
||||
const control = await fullscreen.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(control).not.toBeNull();
|
||||
expect(control!.y, "the toolbar starts inside the preview").toBeGreaterThanOrEqual(box!.y);
|
||||
expect(control!.y + control!.height, "the toolbar fits inside the preview").toBeLessThanOrEqual(
|
||||
box!.y + box!.height,
|
||||
);
|
||||
await viewport.getByRole("button", { name: "Zoom in" }).click();
|
||||
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeEnabled();
|
||||
await viewport.getByRole("button", { name: "Reset view" }).click();
|
||||
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeDisabled();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "createTerminal"
|
||||
| "killTerminal"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
@@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "removePlugin"
|
||||
| "reloadPlugin"
|
||||
| "setWorkspaceTitle"
|
||||
| "setWorkspaceLabel"
|
||||
>;
|
||||
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function contribute(client) {
|
||||
}`;
|
||||
}
|
||||
|
||||
async function withDirectoryPlugin(options: {
|
||||
export async function withDirectoryPlugin(options: {
|
||||
pluginId: string;
|
||||
source: string;
|
||||
run: () => Promise<void>;
|
||||
|
||||
@@ -157,8 +157,11 @@ export interface SeedDaemonClient {
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { sourceAgentId?: string },
|
||||
options?: { sourceAgentId?: string; activeTurnBehavior?: "interrupt" | "steer" | "queue" },
|
||||
): Promise<void>;
|
||||
listAgentBackgroundWork(
|
||||
agentId: string,
|
||||
): Promise<import("@getpaseo/client").PaseoAgentBackgroundWorkItem[]>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings";
|
||||
import { visibleTestId } from "./plugin-sidebar-items";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function withSidebarViews(
|
||||
page: Page,
|
||||
run: (views: ReturnType<typeof sidebarViews>) => Promise<void>,
|
||||
) {
|
||||
const example = await copyPluginExample("sidebar-views");
|
||||
const entry = path.join(example.directory, "index.client.tsx");
|
||||
const original = await readFile(entry, "utf8");
|
||||
await writeFile(
|
||||
entry,
|
||||
original.replace(
|
||||
"const cleanups = [",
|
||||
'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [',
|
||||
),
|
||||
);
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-views-",
|
||||
title: "Sidebar view workspace",
|
||||
});
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await run(sidebarViews(page, workspace.workspaceId, client));
|
||||
} finally {
|
||||
await client.removePlugin("sidebar-views");
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await example.cleanup();
|
||||
}
|
||||
}
|
||||
function sidebarViews(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
) {
|
||||
const builtinRow = () =>
|
||||
visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
async function choose(title: string) {
|
||||
await openSidebarNavSettings(page);
|
||||
await page.getByRole("button", { name: /^View:/ }).click();
|
||||
await page.getByRole("menuitem", { name: title, exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible();
|
||||
await leaveSettings(page);
|
||||
}
|
||||
async function expectDefault() {
|
||||
await expect(builtinRow()).toBeVisible();
|
||||
}
|
||||
async function expectPlugin() {
|
||||
await expect(
|
||||
visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(builtinRow()).toHaveCount(0);
|
||||
await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
|
||||
}
|
||||
return {
|
||||
expectDefault,
|
||||
async chooseWorkspaces() {
|
||||
await choose("Example workspaces");
|
||||
await expectPlugin();
|
||||
},
|
||||
async exerciseExamples() {
|
||||
await choose("Example agents");
|
||||
await expect(
|
||||
visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }),
|
||||
).toBeVisible();
|
||||
await choose("Example nested agents");
|
||||
await expectPlugin();
|
||||
await choose("Example label folders");
|
||||
const slot = visibleTestId(page, "plugin-sidebar-view");
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toContain("Inbox");
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
await slot.getByRole("button", { name: "Move to Later", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Later"]);
|
||||
await client.setWorkspaceLabel({
|
||||
workspaceId,
|
||||
label: { name: "Unlabelled", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
await client.setWorkspaceLabel({
|
||||
workspaceId,
|
||||
label: { name: "Later", color: "red" },
|
||||
assigned: false,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Unlabelled"]);
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Inbox"]);
|
||||
},
|
||||
async exerciseTerminals() {
|
||||
await choose("Example terminals");
|
||||
const slot = visibleTestId(page, "plugin-sidebar-view");
|
||||
await slot
|
||||
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
|
||||
.click();
|
||||
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
|
||||
const workspace = (await client.fetchWorkspaces()).entries.find(
|
||||
(item) => item.id === workspaceId,
|
||||
)!;
|
||||
const created = await client.createTerminal(
|
||||
workspace.workspaceDirectory,
|
||||
"Sidebar terminal",
|
||||
undefined,
|
||||
{ workspaceId },
|
||||
);
|
||||
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
|
||||
try {
|
||||
await expect(
|
||||
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
|
||||
).toBeVisible();
|
||||
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
|
||||
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
|
||||
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
|
||||
await expect
|
||||
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
|
||||
.not.toContain(created.terminal!.id);
|
||||
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.killTerminal(created.terminal.id);
|
||||
}
|
||||
},
|
||||
async restoreDefault() {
|
||||
await choose("Default view");
|
||||
await expectDefault();
|
||||
},
|
||||
async recoverFromFailure() {
|
||||
await choose("Broken example");
|
||||
await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Use default view", exact: true }).click();
|
||||
await expectDefault();
|
||||
},
|
||||
async disableChosenView() {
|
||||
await client.disablePlugin("sidebar-views");
|
||||
await expectDefault();
|
||||
await openSidebarNavSettings(page);
|
||||
await expect(
|
||||
page.getByText("The selected view is unavailable or disabled. Showing Default view.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await leaveSettings(page);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
|
||||
// Every footer variant (running, completed with Copy, completed with only its duration) gets
|
||||
// this content height, so a turn ending swaps the footer's controls without moving the rows
|
||||
// above it.
|
||||
const TURN_FOOTER_CONTENT_HEIGHT = 24;
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
@@ -208,7 +212,11 @@ function CompletedTurnFooter({
|
||||
|
||||
function TurnFooterRow({ children }: { children: ReactNode }) {
|
||||
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
|
||||
return <View style={rowStyle}>{children}</View>;
|
||||
return (
|
||||
<View style={rowStyle} testID="turn-footer">
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const stylesheet = StyleSheet.create((theme) => ({
|
||||
@@ -218,18 +226,22 @@ 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",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
minHeight: 24,
|
||||
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
|
||||
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
|
||||
},
|
||||
turnFooterContent: {
|
||||
height: 24,
|
||||
height: TURN_FOOTER_CONTENT_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { createHostBackgroundWorkModel } from "./host";
|
||||
|
||||
test("retries a failed observation and receives subsequent updates", async () => {
|
||||
interface Observer {
|
||||
snapshot(): void;
|
||||
update(message: SessionOutboundMessage): void;
|
||||
error(error: unknown): void;
|
||||
}
|
||||
const observers: Observer[] = [];
|
||||
let releases = 0;
|
||||
const item = {
|
||||
id: "shell",
|
||||
kind: "shell",
|
||||
description: "sleep",
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const client = {
|
||||
getLastServerInfoMessage: () => ({ features: { agentBackgroundWork: true } }),
|
||||
stopAgentBackgroundWork: async () => {},
|
||||
listAgentBackgroundWork: async () => [item],
|
||||
observeAgentBackgroundWork: () => ({
|
||||
subscribe: (observer: Observer) => {
|
||||
observers.push(observer);
|
||||
},
|
||||
release: async () => {
|
||||
releases++;
|
||||
},
|
||||
}),
|
||||
};
|
||||
const model = createHostBackgroundWorkModel("host", "agent", {
|
||||
getClient: () => client,
|
||||
subscribe: () => () => {},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
observers[0].snapshot();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observers[0].error(new Error("subscription failed"));
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
|
||||
model.retry();
|
||||
expect(observers).toHaveLength(2);
|
||||
expect(releases).toBe(1);
|
||||
observers[1].snapshot();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observers[0].error(new Error("late error"));
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observers[1].update({
|
||||
type: "agent.background_work.update",
|
||||
payload: { agentId: "agent", items: [] },
|
||||
});
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("starts observing after the same client reconnects", async () => {
|
||||
let connected = false;
|
||||
let notify = () => {};
|
||||
let snapshot = () => {};
|
||||
const item = {
|
||||
id: "shell",
|
||||
kind: "shell",
|
||||
description: "sleep",
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const client = {
|
||||
getLastServerInfoMessage: () =>
|
||||
connected ? { features: { agentBackgroundWork: true } } : null,
|
||||
stopAgentBackgroundWork: async () => {},
|
||||
listAgentBackgroundWork: async () => [item],
|
||||
observeAgentBackgroundWork: () => ({
|
||||
subscribe: (observer: { snapshot(): void }) => {
|
||||
snapshot = observer.snapshot;
|
||||
},
|
||||
release: async () => {},
|
||||
}),
|
||||
};
|
||||
const model = createHostBackgroundWorkModel("host", "agent", {
|
||||
getClient: () => client,
|
||||
subscribe: (listener) => {
|
||||
notify = listener;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
connected = true;
|
||||
notify();
|
||||
snapshot();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { createBackgroundWorkModel } from "./model";
|
||||
|
||||
interface BackgroundWorkClient {
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { agentBackgroundWork?: boolean; agentBackgroundWorkStop?: boolean } | null;
|
||||
} | null;
|
||||
stopAgentBackgroundWork(agentId: string, item: PaseoAgentBackgroundWorkItem): Promise<void>;
|
||||
listAgentBackgroundWork(agentId: string): Promise<PaseoAgentBackgroundWorkItem[]>;
|
||||
observeAgentBackgroundWork(): {
|
||||
subscribe(observer: {
|
||||
snapshot(): void;
|
||||
update(message: SessionOutboundMessage): void;
|
||||
error(error: unknown): void;
|
||||
}): unknown;
|
||||
release(): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
interface BackgroundWorkHost {
|
||||
getClient(): BackgroundWorkClient | null;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
function hostFor(serverId: string): BackgroundWorkHost {
|
||||
const host = getHostRuntimeStore();
|
||||
return {
|
||||
getClient: () => host.getSnapshot(serverId)?.client ?? null,
|
||||
subscribe: (listener) => host.subscribe(serverId, listener),
|
||||
};
|
||||
}
|
||||
|
||||
export function createHostBackgroundWorkModel(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
host: BackgroundWorkHost = hostFor(serverId),
|
||||
) {
|
||||
let client: BackgroundWorkClient | null = null;
|
||||
const controls = (items: PaseoAgentBackgroundWorkItem[]) => {
|
||||
// COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03.
|
||||
if (client?.getLastServerInfoMessage()?.features?.agentBackgroundWorkStop === true)
|
||||
return items;
|
||||
return items.map(({ canStop: _canStop, stopToken: _stopToken, ...item }) => item);
|
||||
};
|
||||
return createBackgroundWorkModel({
|
||||
stop: (item) => {
|
||||
if (!client) return Promise.reject(new Error("Host disconnected"));
|
||||
return client.stopAgentBackgroundWork(agentId, item);
|
||||
},
|
||||
list: () => {
|
||||
if (!client) return Promise.reject(new Error("Host disconnected"));
|
||||
return client.listAgentBackgroundWork(agentId).then(controls);
|
||||
},
|
||||
observe: ({ snapshot, update, disconnected, failed }) => {
|
||||
let release: (() => void) | undefined;
|
||||
let currentObservation:
|
||||
| ReturnType<BackgroundWorkClient["observeAgentBackgroundWork"]>
|
||||
| undefined;
|
||||
const sync = () => {
|
||||
const candidate = host.getClient();
|
||||
// The runtime keeps the client across reconnects, but clears server info until handshake.
|
||||
const next = candidate?.getLastServerInfoMessage() ? candidate : null;
|
||||
if (next === client) return;
|
||||
const previousRelease = release;
|
||||
client = next;
|
||||
release = undefined;
|
||||
disconnected();
|
||||
previousRelease?.();
|
||||
if (!client) return;
|
||||
// COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06.
|
||||
if (client.getLastServerInfoMessage()?.features?.agentBackgroundWork !== true) {
|
||||
failed();
|
||||
return;
|
||||
}
|
||||
const observedClient = client;
|
||||
const observation = client.observeAgentBackgroundWork();
|
||||
currentObservation = observation;
|
||||
observation.subscribe({
|
||||
snapshot: () => {
|
||||
if (client === observedClient && currentObservation === observation) snapshot();
|
||||
},
|
||||
update: (message) => {
|
||||
if (
|
||||
client === observedClient &&
|
||||
currentObservation === observation &&
|
||||
message.type === "agent.background_work.update" &&
|
||||
message.payload.agentId === agentId
|
||||
)
|
||||
update(controls(message.payload.items));
|
||||
},
|
||||
error: () => {
|
||||
if (client === observedClient && currentObservation === observation) failed();
|
||||
},
|
||||
});
|
||||
release = () => {
|
||||
if (currentObservation === observation) currentObservation = undefined;
|
||||
void observation
|
||||
.release()
|
||||
.catch((error: unknown) =>
|
||||
console.error("Background work subscription cleanup failed", error),
|
||||
);
|
||||
};
|
||||
};
|
||||
const unsubscribe = host.subscribe(sync);
|
||||
sync();
|
||||
return () => {
|
||||
unsubscribe();
|
||||
client = null;
|
||||
release?.();
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
|
||||
import { LiveElapsed } from "@/components/message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
|
||||
import { createHostBackgroundWorkModel } from "./host";
|
||||
import { backgroundWorkText } from "./presentation";
|
||||
export { createHostBackgroundWorkModel } from "./host";
|
||||
|
||||
function kindKey(kind: string): "shell" | "monitor" | "other" {
|
||||
if (kind === "shell" || kind === "monitor") return kind;
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function BackgroundWorkTrack({
|
||||
model,
|
||||
count,
|
||||
}: {
|
||||
model: ReturnType<typeof createHostBackgroundWorkModel>;
|
||||
count: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { items, status, stops } = useSyncExternalStore(
|
||||
model.subscribe,
|
||||
model.getSnapshot,
|
||||
model.getSnapshot,
|
||||
);
|
||||
const missing = Object.values(stops ?? {}).filter(
|
||||
(entry) => !items?.some((item) => item.stopToken === entry.item.stopToken),
|
||||
);
|
||||
const rows = [...(items ?? []), ...missing.map((entry) => entry.item)];
|
||||
if (count === 0 && rows.length === 0) return null;
|
||||
const visibleCount = items?.length || count;
|
||||
return (
|
||||
<ComposerTrackPill
|
||||
testID="background-work"
|
||||
panelTitle={t("message.backgroundWork.title")}
|
||||
segments={[
|
||||
{
|
||||
bucket: "running",
|
||||
text:
|
||||
visibleCount === 1
|
||||
? t("message.backgroundWork.pillOne")
|
||||
: t("message.backgroundWork.pillMany", { count: visibleCount }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{status === "loading" || status === "stale" ? (
|
||||
<ComposerTrackRow>
|
||||
<Text style={styles.facts}>{t("message.backgroundWork.waiting")}</Text>
|
||||
</ComposerTrackRow>
|
||||
) : null}
|
||||
{status === "error" ? (
|
||||
<>
|
||||
<ComposerTrackRow>
|
||||
<Text style={styles.facts}>{t("message.backgroundWork.error")}</Text>
|
||||
</ComposerTrackRow>
|
||||
<ComposerTrackRow
|
||||
onPress={model.retry}
|
||||
closeOnSelect={false}
|
||||
accessibilityLabel={t("message.backgroundWork.retry")}
|
||||
>
|
||||
<Text style={styles.title}>{t("message.backgroundWork.retry")}</Text>
|
||||
</ComposerTrackRow>
|
||||
</>
|
||||
) : null}
|
||||
{rows.map((item) => (
|
||||
<BackgroundWorkRow
|
||||
key={item.stopToken ?? item.id}
|
||||
item={item}
|
||||
model={model}
|
||||
live={Boolean(items?.some((entry) => entry.stopToken === item.stopToken))}
|
||||
ready={status === "ready"}
|
||||
stopStatus={stops?.[item.stopToken ?? ""]?.status}
|
||||
/>
|
||||
))}
|
||||
</ComposerTrackPill>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundWorkRow({
|
||||
item,
|
||||
model,
|
||||
live,
|
||||
ready,
|
||||
stopStatus,
|
||||
}: {
|
||||
item: PaseoAgentBackgroundWorkItem;
|
||||
model: ReturnType<typeof createHostBackgroundWorkModel>;
|
||||
live: boolean;
|
||||
ready: boolean;
|
||||
stopStatus?: "pending" | "error";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const kind = t(`message.backgroundWork.${kindKey(item.kind)}`);
|
||||
const stop = useCallback(() => {
|
||||
void model.stopItem(item);
|
||||
}, [model, item]);
|
||||
const dismiss = useCallback(
|
||||
() => model.dismissStop(item.stopToken ?? ""),
|
||||
[model, item.stopToken],
|
||||
);
|
||||
let detail = t("message.backgroundWork.notRunning");
|
||||
if (stopStatus === "pending") detail = t("message.backgroundWork.stopping");
|
||||
const { title, command } = backgroundWorkText(item, kind);
|
||||
const action = item.canStop && ready && live;
|
||||
const label =
|
||||
stopStatus === "pending"
|
||||
? t("message.backgroundWork.stopping")
|
||||
: t("message.backgroundWork.stop");
|
||||
return (
|
||||
<ComposerTrackRow>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{command ? <Text style={styles.facts}>{command}</Text> : null}
|
||||
<Text style={styles.facts}>
|
||||
{kind} ·{" "}
|
||||
{live ? (
|
||||
<LiveElapsed startedAt={new Date(item.startedAt)} active style={styles.facts} />
|
||||
) : (
|
||||
detail
|
||||
)}
|
||||
</Text>
|
||||
{stopStatus === "error" ? (
|
||||
<Text style={styles.facts}>{t("message.backgroundWork.stopError")}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{action ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
accessibilityLabel={`${t("message.backgroundWork.stop")} ${title}`}
|
||||
disabled={stopStatus === "pending"}
|
||||
onPress={stop}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
) : null}
|
||||
{!live && stopStatus === "error" ? (
|
||||
<Button variant="ghost" size="sm" onPress={dismiss}>
|
||||
{t("message.backgroundWork.dismiss")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ComposerTrackRow>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: { flexGrow: 1, flexShrink: 1, flexBasis: "auto", minWidth: 0 },
|
||||
title: { fontSize: theme.fontSize.base, color: theme.colors.foreground },
|
||||
facts: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
}));
|
||||
@@ -0,0 +1,233 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createBackgroundWorkModel } from "./model";
|
||||
|
||||
test("keeps a newer update when an earlier list resolves after it", async () => {
|
||||
let resolve!: (
|
||||
value: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
|
||||
) => void;
|
||||
let update!: (
|
||||
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
|
||||
) => void;
|
||||
let snapshot!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
update = callbacks.update;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
expect(model.getSnapshot().items).toBeNull();
|
||||
snapshot();
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
update([item]);
|
||||
resolve([]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().items).toEqual([item]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("retains the last inventory while reconnecting and replaces it after resync", async () => {
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
const reads: Array<(value: (typeof item)[]) => void> = [];
|
||||
let snapshot!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () => new Promise((resolve) => reads.push(resolve)),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
reads[0]([item]);
|
||||
await Promise.resolve();
|
||||
snapshot();
|
||||
expect(model.getSnapshot().items).toEqual([item]);
|
||||
reads[1]([]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().items).toEqual([]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("ignores a list response from a connection that was replaced", async () => {
|
||||
let snapshot!: () => void;
|
||||
let disconnected!: () => void;
|
||||
let resolve!: (
|
||||
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
|
||||
) => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
disconnected = callbacks.disconnected;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
disconnected();
|
||||
resolve([{ id: "old", kind: "shell", description: null, startedAt: "2026-01-01T00:00:00Z" }]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().items).toBeNull();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("retains stale items on failure and retries the inventory", async () => {
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
let snapshot!: () => void;
|
||||
let resolve!: (items: (typeof item)[]) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: () =>
|
||||
new Promise((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
resolve([item]);
|
||||
await Promise.resolve();
|
||||
snapshot();
|
||||
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
|
||||
reject(new Error("offline"));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
|
||||
model.retry();
|
||||
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
|
||||
resolve([]);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("reports an initial read failure without claiming an empty inventory", async () => {
|
||||
let snapshot!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
observe: (callbacks) => {
|
||||
snapshot = callbacks.snapshot;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
snapshot();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("recreates a failed observation on retry and resumes live updates", async () => {
|
||||
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
|
||||
let observation!: Parameters<Parameters<typeof createBackgroundWorkModel>[0]["observe"]>[0];
|
||||
let starts = 0;
|
||||
let releases = 0;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => [item],
|
||||
observe: (callbacks) => {
|
||||
observation = callbacks;
|
||||
starts++;
|
||||
return () => {
|
||||
releases++;
|
||||
};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
observation.failed();
|
||||
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
|
||||
model.retry();
|
||||
expect(starts).toBe(2);
|
||||
expect(releases).toBe(1);
|
||||
observation.snapshot();
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
|
||||
observation.update([]);
|
||||
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("keeps a stop pending across a roster removal and exposes a failed outcome", async () => {
|
||||
const item = {
|
||||
id: "1",
|
||||
kind: "shell",
|
||||
description: "sleep",
|
||||
startedAt: "2026-01-01",
|
||||
canStop: true,
|
||||
stopToken: "generation-1",
|
||||
};
|
||||
let update!: (items: (typeof item)[]) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => [item],
|
||||
stop: () =>
|
||||
new Promise((_, fail) => {
|
||||
reject = fail;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
update = callbacks.update;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
update([item]);
|
||||
const pending = model.stopItem(item);
|
||||
update([]);
|
||||
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "pending", item });
|
||||
reject(new Error("connection lost"));
|
||||
await pending;
|
||||
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "error", item });
|
||||
model.dismissStop("generation-1");
|
||||
expect(model.getSnapshot().stops).toEqual({});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("ignores a stop response from a disconnected generation", async () => {
|
||||
const first = {
|
||||
id: "1",
|
||||
kind: "shell",
|
||||
description: "old",
|
||||
startedAt: "2026-01-01",
|
||||
canStop: true,
|
||||
stopToken: "first",
|
||||
};
|
||||
const replacement = { ...first, description: "new", stopToken: "second" };
|
||||
let update!: (items: (typeof first)[]) => void;
|
||||
let disconnect!: () => void;
|
||||
let resolve!: () => void;
|
||||
const model = createBackgroundWorkModel({
|
||||
list: async () => [],
|
||||
stop: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
observe: (callbacks) => {
|
||||
update = callbacks.update;
|
||||
disconnect = callbacks.disconnected;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const unsubscribe = model.subscribe(() => {});
|
||||
update([first]);
|
||||
const pending = model.stopItem(first);
|
||||
disconnect();
|
||||
update([replacement]);
|
||||
resolve();
|
||||
await pending;
|
||||
expect(model.getSnapshot().items).toEqual([replacement]);
|
||||
expect(model.getSnapshot().stops?.first).toEqual({ status: "error", item: first });
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { PaseoAgentBackgroundWorkItem as AgentBackgroundWorkItem } from "@getpaseo/client";
|
||||
|
||||
interface Port {
|
||||
list(): Promise<AgentBackgroundWorkItem[]>;
|
||||
stop?(item: AgentBackgroundWorkItem): Promise<void>;
|
||||
observe(callbacks: {
|
||||
snapshot(): void;
|
||||
disconnected(): void;
|
||||
failed(): void;
|
||||
update(items: AgentBackgroundWorkItem[]): void;
|
||||
}): () => void;
|
||||
}
|
||||
|
||||
interface BackgroundWorkState {
|
||||
status: "loading" | "stale" | "ready" | "error";
|
||||
items: AgentBackgroundWorkItem[] | null;
|
||||
stops?: Record<string, { status: "pending" | "error"; item: AgentBackgroundWorkItem }>;
|
||||
}
|
||||
|
||||
export function createBackgroundWorkModel(port: Port) {
|
||||
let state: BackgroundWorkState = { status: "loading", items: null };
|
||||
let sequence = 0;
|
||||
let stopGeneration = 0;
|
||||
let stop: (() => void) | undefined;
|
||||
let observationFailed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const publish = (next: BackgroundWorkState) => {
|
||||
state =
|
||||
next.stops === undefined && state.stops === undefined
|
||||
? next
|
||||
: { ...next, stops: next.stops ?? state.stops };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
const refresh = async () => {
|
||||
const request = ++sequence;
|
||||
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
|
||||
try {
|
||||
const items = await port.list();
|
||||
if (request === sequence) publish({ status: "ready", items });
|
||||
} catch {
|
||||
if (request === sequence) publish({ status: "error", items: state.items });
|
||||
}
|
||||
};
|
||||
const start = () => {
|
||||
observationFailed = false;
|
||||
stop = port.observe({
|
||||
snapshot: () => {
|
||||
void refresh();
|
||||
},
|
||||
disconnected: () => {
|
||||
sequence++;
|
||||
stopGeneration++;
|
||||
const stops =
|
||||
state.stops &&
|
||||
Object.fromEntries(
|
||||
Object.entries(state.stops).map(([id, entry]) => [
|
||||
id,
|
||||
{ ...entry, status: "error" as const },
|
||||
]),
|
||||
);
|
||||
publish({ status: state.items === null ? "loading" : "stale", items: state.items, stops });
|
||||
},
|
||||
failed: () => {
|
||||
observationFailed = true;
|
||||
sequence++;
|
||||
publish({ status: "error", items: state.items });
|
||||
},
|
||||
update: (items) => {
|
||||
sequence++;
|
||||
publish({ status: "ready", items });
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
getSnapshot: () => state,
|
||||
dismissStop: (token: string) => {
|
||||
const stops = { ...state.stops };
|
||||
delete stops[token];
|
||||
publish({ ...state, stops });
|
||||
},
|
||||
stopItem: async (item: AgentBackgroundWorkItem) => {
|
||||
const token = item.stopToken;
|
||||
if (!port.stop || !token || state.stops?.[token]?.status === "pending") return;
|
||||
if (!state.items?.some((entry) => entry.stopToken === token)) return;
|
||||
const generation = stopGeneration;
|
||||
publish({ ...state, stops: { ...state.stops, [token]: { status: "pending", item } } });
|
||||
try {
|
||||
await port.stop(item);
|
||||
if (generation !== stopGeneration) return;
|
||||
const stops = { ...state.stops };
|
||||
delete stops[token];
|
||||
publish({ ...state, stops });
|
||||
} catch {
|
||||
if (generation === stopGeneration)
|
||||
publish({ ...state, stops: { ...state.stops, [token]: { status: "error", item } } });
|
||||
}
|
||||
},
|
||||
retry: () => {
|
||||
if (observationFailed) {
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
|
||||
start();
|
||||
} else {
|
||||
void refresh();
|
||||
}
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!stop) start();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) {
|
||||
sequence++;
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { backgroundWorkText } from "./presentation";
|
||||
|
||||
test("shows descriptions and commands once, with useful fallbacks", () => {
|
||||
expect(
|
||||
backgroundWorkText({ description: "Watch build", command: "tail -f build.log" }, "Monitor"),
|
||||
).toEqual({ title: "Watch build", command: "tail -f build.log" });
|
||||
expect(backgroundWorkText({ description: null, command: "sleep 10" }, "Shell")).toEqual({
|
||||
title: "sleep 10",
|
||||
command: null,
|
||||
});
|
||||
expect(backgroundWorkText({ description: "sleep 10", command: "sleep 10" }, "Shell")).toEqual({
|
||||
title: "sleep 10",
|
||||
command: null,
|
||||
});
|
||||
expect(backgroundWorkText({ description: null }, "Shell")).toEqual({
|
||||
title: "Shell",
|
||||
command: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export function backgroundWorkText(
|
||||
item: { description: string | null; command?: string },
|
||||
kind: string,
|
||||
): { title: string; command: string | null } {
|
||||
const title = item.description ?? item.command ?? kind;
|
||||
return { title, command: item.command && item.command !== title ? item.command : null };
|
||||
}
|
||||
@@ -41,7 +41,15 @@ export function BranchSwitcher({
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
||||
const {
|
||||
branchOptions,
|
||||
isLoadingBranches,
|
||||
isFetchingBranches,
|
||||
branchError,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
handleBranchSelect,
|
||||
} = useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId: serverId,
|
||||
normalizedWorkspaceId: workspaceId,
|
||||
@@ -73,6 +81,22 @@ export function BranchSwitcher({
|
||||
[branchLeadingSlot],
|
||||
);
|
||||
|
||||
let emptyText = t("branchSwitcher.empty");
|
||||
if (isLoadingBranches) emptyText = t("common.states.loading");
|
||||
else if (branchError) emptyText = "";
|
||||
|
||||
const errorFooter = useMemo(
|
||||
() =>
|
||||
branchError ? (
|
||||
<View style={styles.footer}>
|
||||
<Text accessibilityRole="alert" style={styles.error}>
|
||||
{isFetchingBranches ? t("common.states.loading") : t("branchSwitcher.failedToLoad")}
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined,
|
||||
[branchError, isFetchingBranches, t],
|
||||
);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return null;
|
||||
}
|
||||
@@ -85,6 +109,7 @@ export function BranchSwitcher({
|
||||
testID={testID}
|
||||
label={currentBranchName}
|
||||
open={isOpen}
|
||||
disabled={!isConnected}
|
||||
onPress={handleOpen}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("branchSwitcher.currentBranch", {
|
||||
@@ -103,7 +128,8 @@ export function BranchSwitcher({
|
||||
searchable
|
||||
placeholder={t("branchSwitcher.placeholder")}
|
||||
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
|
||||
emptyText={t("branchSwitcher.empty")}
|
||||
emptyText={emptyText}
|
||||
footer={errorFooter}
|
||||
title={t("branchSwitcher.title")}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
@@ -122,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
|
||||
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
|
||||
tooltipText: {
|
||||
color: theme.colors.popoverForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SidebarViewSlot } from "@/plugins/sidebar-view";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
@@ -642,31 +643,33 @@ function MobileSidebar({
|
||||
)}
|
||||
<SidebarSeparator />
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarViewSlot dismiss={handleWorkspacePress}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
</SidebarViewSlot>
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
@@ -838,31 +841,33 @@ function DesktopSidebar({
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarViewSlot dismiss={closeSidebar}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
</SidebarViewSlot>
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ function MermaidFenceHostImpl({
|
||||
() => [
|
||||
getDiagramBoxStyle(textStyle),
|
||||
containerStyle,
|
||||
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
|
||||
{ height: Math.min(runtimeHeight, MAX_PREVIEW_HEIGHT), minHeight: MIN_DIAGRAM_BOX_HEIGHT },
|
||||
],
|
||||
[runtimeHeight, textStyle],
|
||||
);
|
||||
@@ -111,20 +111,22 @@ function MermaidFenceHostImpl({
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<ZoomableViewport
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
actions={actions}
|
||||
contentSize={diagramSize}
|
||||
style={viewportStyle}
|
||||
testID="mermaid-viewport"
|
||||
wheelActivation="modifier"
|
||||
>
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</ZoomableViewport>
|
||||
<div inert={!diagramVisible} style={boundaryStyle}>
|
||||
<ZoomableViewport
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
actions={actions}
|
||||
contentSize={diagramSize}
|
||||
style={viewportStyle}
|
||||
testID="mermaid-viewport"
|
||||
wheelActivation="modifier"
|
||||
>
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</ZoomableViewport>
|
||||
</div>
|
||||
{isFullscreen && visible ? (
|
||||
<MermaidFullscreenViewer
|
||||
source={visible.source}
|
||||
@@ -142,6 +144,8 @@ const MEASURING_SIZE = { width: 240, height: 240 };
|
||||
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
|
||||
*/
|
||||
const MIN_DIAGRAM_BOX_HEIGHT = 56;
|
||||
const MAX_PREVIEW_HEIGHT = 480;
|
||||
const boundaryStyle: React.CSSProperties = { display: "contents" };
|
||||
const sourceContainerStyle: ViewStyle = { position: "relative" };
|
||||
/**
|
||||
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
|
||||
|
||||
@@ -12,7 +12,11 @@ import { Portal } from "@gorhom/portal";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import {
|
||||
Autocomplete,
|
||||
type AutocompleteOption,
|
||||
type AutocompleteStatus,
|
||||
} from "@/components/ui/autocomplete";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
@@ -37,10 +41,7 @@ interface AutocompletePopoverProps {
|
||||
options: readonly AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
isLoading?: boolean;
|
||||
errorMessage?: string;
|
||||
loadingText?: string;
|
||||
emptyText?: string;
|
||||
status?: AutocompleteStatus | null;
|
||||
}
|
||||
|
||||
export function AutocompletePopover({
|
||||
@@ -50,10 +51,7 @@ export function AutocompletePopover({
|
||||
options,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
loadingText,
|
||||
emptyText,
|
||||
status,
|
||||
}: AutocompletePopoverProps): ReactElement | null {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
@@ -154,10 +152,7 @@ export function AutocompletePopover({
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
isLoading={isLoading}
|
||||
errorMessage={errorMessage}
|
||||
loadingText={loadingText}
|
||||
emptyText={emptyText}
|
||||
status={status}
|
||||
/>
|
||||
</Animated.View>
|
||||
</KeyboardTranslateView>
|
||||
|
||||
@@ -9,32 +9,79 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { File, Folder } from "lucide-react-native";
|
||||
import {
|
||||
Archive,
|
||||
CircleAlert,
|
||||
File,
|
||||
Folder,
|
||||
Puzzle,
|
||||
RotateCw,
|
||||
Sparkles,
|
||||
SquarePen,
|
||||
SquareSlash,
|
||||
} from "lucide-react-native";
|
||||
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { Button } from "./button";
|
||||
import { HighlightedText } from "./highlighted-text";
|
||||
import { mutedIconColorMapping } from "./icon-color";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
export type AutocompleteOptionIcon =
|
||||
| "command"
|
||||
| "skill"
|
||||
| "plugin"
|
||||
| "archive"
|
||||
| "new-draft"
|
||||
| "file"
|
||||
| "directory";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
icon: AutocompleteOptionIcon;
|
||||
/** Spans of `label` the query matched. */
|
||||
labelMatches?: readonly MatchRange[];
|
||||
argumentHint?: string;
|
||||
description?: string;
|
||||
kind?: "command" | "file" | "directory";
|
||||
/** Where the option comes from, when that is not the provider itself: `Skill`, `Paseo`, a plugin. */
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
/** What the list shows in place of options. */
|
||||
export type AutocompleteStatus =
|
||||
| { kind: "loading"; message: string }
|
||||
| { kind: "empty"; message: string }
|
||||
| { kind: "error"; title: string; reason?: string; onRetry?: () => void };
|
||||
|
||||
interface AutocompleteProps {
|
||||
options: readonly AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
isLoading?: boolean;
|
||||
errorMessage?: string;
|
||||
loadingText?: string;
|
||||
emptyText?: string;
|
||||
status?: AutocompleteStatus | null;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
const BOLT_GLYPH_PATTERN = /\u26A1|\uFE0F/gu;
|
||||
const ROW_ICONS = {
|
||||
command: withUnistyles(SquareSlash, mutedIconColorMapping),
|
||||
skill: withUnistyles(Sparkles, mutedIconColorMapping),
|
||||
plugin: withUnistyles(Puzzle, mutedIconColorMapping),
|
||||
archive: withUnistyles(Archive, mutedIconColorMapping),
|
||||
"new-draft": withUnistyles(SquarePen, mutedIconColorMapping),
|
||||
file: withUnistyles(File, mutedIconColorMapping),
|
||||
directory: withUnistyles(Folder, mutedIconColorMapping),
|
||||
} satisfies Record<AutocompleteOptionIcon, unknown>;
|
||||
const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping);
|
||||
|
||||
const ROW_ICON_SIZE = 14;
|
||||
|
||||
const BOLT_GLYPH_PATTERN = /⚡|️/gu;
|
||||
|
||||
function removeBoltGlyphs(value?: string): string | undefined {
|
||||
if (!value) {
|
||||
@@ -48,7 +95,7 @@ interface AutocompleteRowProps {
|
||||
index: number;
|
||||
option: AutocompleteOption;
|
||||
isSelected: boolean;
|
||||
mutedColor: string;
|
||||
showArgumentHint: boolean;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
|
||||
}
|
||||
@@ -57,13 +104,15 @@ function AutocompleteRow({
|
||||
index,
|
||||
option,
|
||||
isSelected,
|
||||
mutedColor,
|
||||
showArgumentHint,
|
||||
onSelect,
|
||||
onRowLayout,
|
||||
}: AutocompleteRowProps) {
|
||||
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const optionDescription = removeBoltGlyphs(option.description);
|
||||
const isFileOrDir = option.kind === "directory" || option.kind === "file";
|
||||
const label = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const description = removeBoltGlyphs(option.description);
|
||||
const argumentHint = showArgumentHint ? removeBoltGlyphs(option.argumentHint) : undefined;
|
||||
const Icon = ROW_ICONS[option.icon];
|
||||
const labelMatches = label === option.label ? option.labelMatches : undefined;
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => onRowLayout(index, event),
|
||||
@@ -79,41 +128,77 @@ function AutocompleteRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable onLayout={handleLayout} onPress={handlePress} style={pressableStyle}>
|
||||
{isFileOrDir ? (
|
||||
<>
|
||||
<View style={styles.itemLeading}>
|
||||
{option.kind === "directory" ? (
|
||||
<Folder size={14} color={mutedColor} />
|
||||
) : (
|
||||
<File size={14} color={mutedColor} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.itemMain}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||
{removeBoltGlyphs(option.detail) ? (
|
||||
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{optionDescription ? (
|
||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||
{optionDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.itemMainRow}>
|
||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||
{optionDescription ? (
|
||||
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
|
||||
{optionDescription}
|
||||
<Pressable
|
||||
onLayout={handleLayout}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
// Name and description only; the hint and tag repeat what the name implies.
|
||||
accessibilityLabel={description ? `${label}, ${description}` : label}
|
||||
>
|
||||
<View style={styles.itemIcon}>
|
||||
<Icon size={ROW_ICON_SIZE} />
|
||||
</View>
|
||||
<HighlightedText
|
||||
text={label}
|
||||
ranges={labelMatches}
|
||||
style={styles.itemLabel}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
{argumentHint ? (
|
||||
<Text style={styles.itemArgumentHint} numberOfLines={1}>
|
||||
{argumentHint}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||
{description ?? ""}
|
||||
</Text>
|
||||
{option.tag ? <StatusBadge label={option.tag} size="xs" /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (status.kind === "error") {
|
||||
return (
|
||||
<View style={styles.statusRow} testID="composer-autocomplete-error">
|
||||
<View style={styles.itemIcon}>
|
||||
<ErrorIcon size={ROW_ICON_SIZE} />
|
||||
</View>
|
||||
<View style={styles.statusText}>
|
||||
<Text style={styles.statusTitle} numberOfLines={2}>
|
||||
{status.title}
|
||||
</Text>
|
||||
{status.reason ? (
|
||||
<Text style={styles.statusReason} numberOfLines={2}>
|
||||
{status.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{status.onRetry ? (
|
||||
<Button variant="secondary" size="sm" leftIcon={RotateCw} onPress={status.onRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.statusRow}
|
||||
testID={status.kind === "loading" ? "composer-autocomplete-loading" : undefined}
|
||||
>
|
||||
{status.kind === "loading" ? (
|
||||
<View style={styles.itemIcon}>
|
||||
<ThemedLoadingSpinner size={ROW_ICON_SIZE} />
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.statusMessage} numberOfLines={2}>
|
||||
{status.message}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,16 +206,11 @@ export function Autocomplete({
|
||||
options,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
errorMessage,
|
||||
loadingText,
|
||||
emptyText,
|
||||
status,
|
||||
maxHeight = 220,
|
||||
}: AutocompleteProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedLoadingText = loadingText ?? t("common.states.loading");
|
||||
const resolvedEmptyText = emptyText ?? t("common.empty.noResults");
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
||||
const viewportHeightRef = useRef(0);
|
||||
@@ -210,117 +290,51 @@ export function Autocomplete({
|
||||
[ensureActiveItemVisible],
|
||||
);
|
||||
|
||||
const selectedOption = options[selectedIndex];
|
||||
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>{resolvedLoadingText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const noResultsMessage = t("common.empty.noResults");
|
||||
const shownStatus = useMemo<AutocompleteStatus | null>(
|
||||
() => status ?? (options.length === 0 ? { kind: "empty", message: noResultsMessage } : null),
|
||||
[noResultsMessage, options.length, status],
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
if (shownStatus) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>{resolvedEmptyText}</Text>
|
||||
</View>
|
||||
<AutocompleteStatusRow status={shownStatus} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.outerWrapper}>
|
||||
{selectedOption?.kind === "command" && selectedOption.description ? (
|
||||
<View style={styles.detailCard}>
|
||||
<Text style={styles.detailLabel} numberOfLines={1}>
|
||||
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
|
||||
</Text>
|
||||
<Text style={styles.detailDescription} numberOfLines={2} ellipsizeMode="tail">
|
||||
{removeBoltGlyphs(selectedOption.description)}
|
||||
</Text>
|
||||
{selectedOption.detail ? (
|
||||
<Text style={styles.detailHint} numberOfLines={1}>
|
||||
{removeBoltGlyphs(selectedOption.detail)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={containerStyle}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<AutocompleteRow
|
||||
key={option.id}
|
||||
index={index}
|
||||
option={option}
|
||||
isSelected={index === selectedIndex}
|
||||
mutedColor={theme.colors.foregroundMuted}
|
||||
onSelect={onSelect}
|
||||
onRowLayout={handleRowLayout}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<View style={containerStyle}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<AutocompleteRow
|
||||
key={option.id}
|
||||
index={index}
|
||||
option={option}
|
||||
isSelected={index === selectedIndex}
|
||||
showArgumentHint={!isCompact}
|
||||
onSelect={onSelect}
|
||||
onRowLayout={handleRowLayout}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
outerWrapper: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailCard: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
...theme.shadow.md,
|
||||
},
|
||||
detailLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
detailDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
detailHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
container: {
|
||||
flexShrink: 1,
|
||||
// Keep a complete selectable row (36), list padding (8), and borders (2).
|
||||
@@ -342,60 +356,63 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
itemLeading: {
|
||||
width: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
itemActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
itemMainRow: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
itemIcon: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
itemHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
justifyContent: "center",
|
||||
},
|
||||
itemLabel: {
|
||||
flexShrink: 0,
|
||||
maxWidth: "60%",
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
itemDetail: {
|
||||
itemArgumentHint: {
|
||||
flexShrink: 0,
|
||||
maxWidth: "30%",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
itemDescription: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
itemDescriptionInline: {
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 44,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
statusText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
statusTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
statusReason: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
statusMessage: {
|
||||
flex: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
emptyItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
})) as unknown as Record<string, object>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -88,6 +88,7 @@ import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useComposerHeight } from "./height";
|
||||
import { ComposerInputPresentation } from "./presentation";
|
||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||
import { pasteLinkedClipboardText } from "@/composer/pasted-text";
|
||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||
import {
|
||||
EditingTextInput,
|
||||
@@ -513,7 +514,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
|
||||
return native instanceof HTMLElement ? native : null;
|
||||
}
|
||||
|
||||
interface PasteImagesEffectArgs {
|
||||
interface PasteEffectArgs {
|
||||
textarea: HTMLElement | null;
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
@@ -522,7 +523,7 @@ interface PasteImagesEffectArgs {
|
||||
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
|
||||
}
|
||||
|
||||
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
||||
function usePasteEffect(args: PasteEffectArgs): void {
|
||||
const {
|
||||
textarea,
|
||||
isConnected,
|
||||
@@ -533,16 +534,20 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
||||
} = args;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !onAddImages) return;
|
||||
if (!isWeb) return;
|
||||
|
||||
if (!textarea) return;
|
||||
|
||||
let disposed = false;
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
|
||||
if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
|
||||
|
||||
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
|
||||
if (imageFiles.length === 0) return;
|
||||
if (imageFiles.length === 0) {
|
||||
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !onAddImages) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1811,7 +1816,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
handleAlternateSendAction();
|
||||
}, [alternateSendAction, handleAlternateSendAction]);
|
||||
|
||||
usePasteImagesEffect({
|
||||
usePasteEffect({
|
||||
textarea: webTextArea,
|
||||
isConnected,
|
||||
disabled,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createMarkdownClipboardContent } from "@/utils/rich-clipboard";
|
||||
import { pasteLinkedClipboardText } from "./pasted-text.web";
|
||||
|
||||
const inputs: HTMLTextAreaElement[] = [];
|
||||
function editor(): HTMLTextAreaElement {
|
||||
const input = document.createElement("textarea");
|
||||
document.body.append(input);
|
||||
inputs.push(input);
|
||||
return input;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const input of inputs.splice(0)) input.remove();
|
||||
});
|
||||
|
||||
function clipboard(html: string, text: string): DataTransfer {
|
||||
const data = new DataTransfer();
|
||||
data.setData("text/html", html);
|
||||
data.setData("text/plain", text);
|
||||
return data;
|
||||
}
|
||||
|
||||
describe("pasted prompt links", () => {
|
||||
it.each([
|
||||
"https://example.com/1",
|
||||
"https://example.com/日本語",
|
||||
"<https://example.com/a b>",
|
||||
"https://例え.テスト/issue",
|
||||
])("keeps complete Markdown from Copy with destination %s", (destination) => {
|
||||
const source = `| Resource | Status |\n| --- | --- |\n| [issue](${destination}) | ~~closed~~ |`;
|
||||
const content = createMarkdownClipboardContent(source);
|
||||
const input = editor();
|
||||
expect(pasteLinkedClipboardText(input, clipboard(content.html, content.plainText))).toBe(false);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
it("converts external links when plain text names the resource but omits a destination", () => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
clipboard(
|
||||
'<a href="https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E">日本語</a> and <a href="https://example.com/missing">another</a>',
|
||||
"[日本語](https://example.com/日本語) and another",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"[日本語](https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E) and [another](https://example.com/missing)",
|
||||
);
|
||||
});
|
||||
it("preserves a labeled resource URL as Markdown", () => {
|
||||
const data = clipboard(
|
||||
'Hey can you take a look at <a href="https://example.com/issues/39150">this FR</a> and...',
|
||||
"Hey can you take a look at this FR and...",
|
||||
);
|
||||
const input = editor();
|
||||
expect(pasteLinkedClipboardText(input, data)).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"Hey can you take a look at [this FR](https://example.com/issues/39150) and...",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves external linked table rows and columns", () => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
clipboard(
|
||||
'<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
|
||||
"Resource\tStatus\nIssue\tclosed",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps table cell separators and line breaks inside their cells", () => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
clipboard(
|
||||
'<table><tr><th>Resource</th><th>Status</th></tr><tr><td><a href="https://example.com/issue">Issue | tracker</a></td><td>closed<br>today</td></tr></table>',
|
||||
"Resource\tStatus\nIssue | tracker\tclosed today",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"| Resource | Status |\n| --- | --- |\n| [Issue \\| tracker](https://example.com/issue) | closed<br>today |",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains headerless tables as HTML without inventing a heading", () => {
|
||||
const html =
|
||||
'<table><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr><tr><td>Other</td><td>open</td></tr></tbody></table>';
|
||||
const input = editor();
|
||||
expect(pasteLinkedClipboardText(input, clipboard(html, "Issue\tclosed\nOther\topen"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(input.value).toBe(html);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["<table></table>", ""],
|
||||
["<table><caption>Context</caption></table>", "\n\nContext"],
|
||||
])("keeps surrounding links when a table has no rows: %s", (table, caption) => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
clipboard(`<a href="https://example.com/">Issue</a>${table}`, "Issue"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(`[Issue](https://example.com/)${caption}`);
|
||||
});
|
||||
|
||||
it("leaves plain text and rich text without links to native paste", () => {
|
||||
const input = editor();
|
||||
expect(pasteLinkedClipboardText(input, clipboard("", "[link](https://example.com)"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(pasteLinkedClipboardText(input, clipboard("<b>hello</b>", "hello"))).toBe(false);
|
||||
expect(pasteLinkedClipboardText(input, null)).toBe(false);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps paragraphs and multiple links", () => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
clipboard(
|
||||
'<p>See <a href="https://example.com/a">one</a></p><p>and <a href="https://example.com/b">two</a></p>',
|
||||
"See one\n\nand two",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"See [one](https://example.com/a)\n\nand [two](https://example.com/b)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Native inputs retain their platform clipboard handling. */
|
||||
export function pasteLinkedClipboardText(
|
||||
_input: HTMLElement,
|
||||
_clipboard: Pick<DataTransfer, "getData"> | null,
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import TurndownService from "turndown";
|
||||
import { tables } from "turndown-plugin-gfm";
|
||||
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
||||
|
||||
const markdown = new TurndownService({
|
||||
codeBlockStyle: "fenced",
|
||||
bulletListMarker: "-",
|
||||
});
|
||||
// Heading tables become Markdown; headerless tables retain their HTML structure.
|
||||
markdown.use(tables);
|
||||
markdown.addRule("rowlessTable", {
|
||||
filter: (node) => node.nodeName === "TABLE" && (node as HTMLTableElement).rows.length === 0,
|
||||
replacement: (content) => (content ? `\n\n${content}\n\n` : ""),
|
||||
});
|
||||
markdown.addRule("tableCell", {
|
||||
filter: ["th", "td"],
|
||||
replacement: (content, node) => {
|
||||
const prefix = node.previousElementSibling ? " " : "| ";
|
||||
const cell = content.trim().replace(/\|/g, "\\|").replace(/ *\n/g, "<br>");
|
||||
return `${prefix}${cell} |`;
|
||||
},
|
||||
});
|
||||
const copiedMarkdown = createAssistantMarkdownParser();
|
||||
|
||||
function linkDestinations(fragment: Document): string[] {
|
||||
return Array.from(fragment.querySelectorAll("a[href]"), (link) => link.getAttribute("href")!);
|
||||
}
|
||||
|
||||
function containsDestinations(plainText: string, destinations: string[]): boolean {
|
||||
if (destinations.every((url) => plainText.includes(url))) return true;
|
||||
|
||||
// Copy normalizes Markdown URLs in HTML (percent encoding, internationalized
|
||||
// domains). Compare parsed destinations using that same parser, not URL spelling.
|
||||
const rendered = new DOMParser().parseFromString(copiedMarkdown.render(plainText), "text/html");
|
||||
const links = new Set(linkDestinations(rendered));
|
||||
return destinations.every((url) => plainText.includes(url) || links.has(url));
|
||||
}
|
||||
|
||||
/** Paste linked HTML as Markdown through the browser's undoable editing command. */
|
||||
export function pasteLinkedClipboardText(
|
||||
input: HTMLElement,
|
||||
clipboard: Pick<DataTransfer, "getData"> | null,
|
||||
): boolean {
|
||||
const html = clipboard?.getData("text/html");
|
||||
if (!html) return false;
|
||||
|
||||
// Parse in a detached document. Clipboard markup is never mounted in the app.
|
||||
const fragment = new DOMParser().parseFromString(html, "text/html");
|
||||
const destinations = linkDestinations(fragment);
|
||||
const plainText = clipboard!.getData("text/plain");
|
||||
// Copy actions already put complete Markdown on the clipboard. Keep that
|
||||
// representation, including tables and formatting, whenever it carries the URLs.
|
||||
if (containsDestinations(plainText, destinations)) return false;
|
||||
const text = markdown.turndown(fragment.body);
|
||||
input.focus();
|
||||
// Direct value assignment drops the textarea's undo history. This also emits
|
||||
// the normal input event so the editor publishes its new draft as for typing.
|
||||
return input.ownerDocument.execCommand("insertText", false, text);
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
refreshAgentInitializationTimeout,
|
||||
} from "@/hooks/use-agent-initialization";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers";
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
|
||||
import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync";
|
||||
@@ -360,8 +359,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const onStream = (message: SessionOutboundMessage) => {
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event;
|
||||
if (
|
||||
event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
@@ -370,18 +367,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
) {
|
||||
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
|
||||
}
|
||||
const turnLiveness = deriveAgentStreamTurnLiveness([
|
||||
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
||||
]);
|
||||
if (turnLiveness.length > 0) {
|
||||
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
||||
}
|
||||
sync.enqueueStreamEvent(agentId, {
|
||||
event: streamEvent,
|
||||
seq,
|
||||
epoch,
|
||||
timestamp: parsedTimestamp,
|
||||
});
|
||||
sync.applyStreamEvent(agentId, { event, seq, epoch, timestamp: new Date(timestamp) });
|
||||
|
||||
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
||||
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
||||
|
||||
@@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
subscriptionId: string;
|
||||
}>;
|
||||
unsubscribeCheckoutDiffCalls: string[];
|
||||
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
||||
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
||||
subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
|
||||
unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
|
||||
} {
|
||||
const handlers: Record<RouterMessageType, RouterHandler[]> = {
|
||||
providers_snapshot_update: [],
|
||||
@@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
subscriptionId: string;
|
||||
}> = [];
|
||||
const unsubscribeCheckoutDiffCalls: string[] = [];
|
||||
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
|
||||
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
|
||||
const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
|
||||
const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
|
||||
|
||||
function on<K extends RouterMessageType>(
|
||||
type: K,
|
||||
|
||||
@@ -41,8 +41,11 @@ export function useReplicaQuery<
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
||||
return useQuery(replicaQueryOptions(input));
|
||||
>(
|
||||
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> {
|
||||
return useQuery(replicaQueryOptions(input), queryClient);
|
||||
}
|
||||
|
||||
export function useFetchQuery<
|
||||
|
||||
@@ -667,15 +667,14 @@ function ChangesRepositoryToolbar({
|
||||
isGitCheckout
|
||||
testID="changes-branch-switcher"
|
||||
/>
|
||||
{model.baseRef ? (
|
||||
{model.baseRef && model.isWorktree ? (
|
||||
<>
|
||||
<ThemedArrowRight size={14} uniProps={foregroundMutedIconColorMapping} />
|
||||
<ThemedArrowRight size={14} uniProps={extraMutedIconColorMapping} />
|
||||
<WorkspaceBaseBranchPicker
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
cwd={model.cwd}
|
||||
baseRef={model.baseRef}
|
||||
isWorktree={model.isWorktree}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -20,7 +20,6 @@ interface Props {
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
baseRef: string;
|
||||
isWorktree: boolean;
|
||||
}
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
@@ -31,13 +30,7 @@ function refLabel(ref: string): string {
|
||||
return ref.replace(/^refs\/(heads|remotes)\//, "");
|
||||
}
|
||||
|
||||
export function WorkspaceBaseBranchPicker({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
baseRef,
|
||||
isWorktree,
|
||||
}: Props) {
|
||||
export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -135,7 +128,7 @@ export function WorkspaceBaseBranchPicker({
|
||||
[t, isPending, error, isError, handleRetry],
|
||||
);
|
||||
|
||||
if (!supported || !isWorktree || !workspaceId) return <Text style={styles.text}>{label}</Text>;
|
||||
if (!supported || !workspaceId) return <Text style={styles.text}>{label}</Text>;
|
||||
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
|
||||
@@ -172,7 +165,9 @@ export function WorkspaceBaseBranchPicker({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
anchor: { flexShrink: 1, minWidth: 0 },
|
||||
// The branch name truncates first so a short base like "main" stays readable; the cap lets
|
||||
// a long base still truncate instead of overflowing the header.
|
||||
anchor: { flexShrink: 0, minWidth: 0, maxWidth: "40%" },
|
||||
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
|
||||
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
shouldClearAgentAttention,
|
||||
type AgentAttentionClearTrigger,
|
||||
} from "@/utils/agent-attention";
|
||||
import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createAgentAttentionController } from "@/utils/agent-attention";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
|
||||
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
|
||||
@@ -14,54 +12,48 @@ interface UseAgentAttentionClearParams {
|
||||
agentId: string | null | undefined;
|
||||
client: DaemonClient | null;
|
||||
isConnected: boolean;
|
||||
requiresAttention: boolean | null | undefined;
|
||||
isAgentDataReady: boolean;
|
||||
requiresAttention: boolean;
|
||||
attentionReason: AttentionReason;
|
||||
isScreenFocused: boolean;
|
||||
focusRequest: object | null;
|
||||
onError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
interface AgentAttentionClearController {
|
||||
clearOnInputFocus: () => void;
|
||||
clearOnPromptSend: () => void;
|
||||
clearOnAgentBlur: () => void;
|
||||
}
|
||||
|
||||
export function useAgentAttentionClear({
|
||||
agentId,
|
||||
client,
|
||||
isConnected,
|
||||
isAgentDataReady,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
isScreenFocused,
|
||||
focusRequest,
|
||||
onError,
|
||||
}: UseAgentAttentionClearParams): AgentAttentionClearController {
|
||||
const [isAppVisible, setIsAppVisible] = useState<boolean>(() => getIsAppActivelyVisible());
|
||||
const deferredFocusEntryClearRef = useRef(false);
|
||||
const prevRequiresAttentionRef = useRef(Boolean(requiresAttention));
|
||||
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppActivelyVisible());
|
||||
const prevScreenFocusedRef = useRef(false);
|
||||
const prevAppVisibleRef = useRef(getIsAppActivelyVisible());
|
||||
|
||||
const clearAttention = useCallback(
|
||||
(trigger: AgentAttentionClearTrigger) => {
|
||||
const resolvedAgentId = agentId?.trim();
|
||||
if (!client || !resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!shouldClearAgentAttention({
|
||||
agentId: resolvedAgentId,
|
||||
isConnected,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
trigger,
|
||||
hasDeferredFocusEntryClear: deferredFocusEntryClearRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deferredFocusEntryClearRef.current = false;
|
||||
client.clearAgentAttention(resolvedAgentId).catch(() => {});
|
||||
},
|
||||
[agentId, attentionReason, client, isConnected, requiresAttention],
|
||||
const clearAttention = useStableEvent(async (id: string) => {
|
||||
if (client) await client.clearAgentAttention(id);
|
||||
});
|
||||
const reportError = useStableEvent((error: unknown) => {
|
||||
// The timeline's reconnect status already explains a lost connection.
|
||||
if (error instanceof DaemonConnectionError && error.code === "DAEMON_CONNECTION_LOST") return;
|
||||
onError(error);
|
||||
});
|
||||
const controller = useMemo(
|
||||
() =>
|
||||
createAgentAttentionController({
|
||||
clear: async () => {
|
||||
if (agentId) await clearAttention(agentId);
|
||||
},
|
||||
onError: reportError,
|
||||
}),
|
||||
[agentId, clearAttention, reportError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,46 +82,28 @@ export function useAgentAttentionClear({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresAttention) {
|
||||
deferredFocusEntryClearRef.current = false;
|
||||
}
|
||||
}, [requiresAttention]);
|
||||
|
||||
useEffect(() => {
|
||||
const isActivelyViewed = isScreenFocused && isAppVisible;
|
||||
if (
|
||||
!prevRequiresAttentionRef.current &&
|
||||
Boolean(requiresAttention) &&
|
||||
prevActivelyViewedRef.current &&
|
||||
isActivelyViewed
|
||||
) {
|
||||
deferredFocusEntryClearRef.current = true;
|
||||
}
|
||||
prevRequiresAttentionRef.current = Boolean(requiresAttention);
|
||||
prevActivelyViewedRef.current = isActivelyViewed;
|
||||
}, [isAppVisible, isScreenFocused, requiresAttention]);
|
||||
|
||||
useEffect(() => {
|
||||
const enteredScreenFocus = !prevScreenFocusedRef.current && isScreenFocused && isAppVisible;
|
||||
const resumedIntoFocusedAgent = !prevAppVisibleRef.current && isAppVisible && isScreenFocused;
|
||||
|
||||
if (enteredScreenFocus || resumedIntoFocusedAgent) {
|
||||
clearAttention("focus-entry");
|
||||
}
|
||||
|
||||
prevScreenFocusedRef.current = isScreenFocused;
|
||||
prevAppVisibleRef.current = isAppVisible;
|
||||
}, [clearAttention, isAppVisible, isScreenFocused]);
|
||||
controller.update({
|
||||
focused: isScreenFocused && isAppVisible,
|
||||
ready: Boolean(client && agentId && isConnected && isAgentDataReady),
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
focusRequest,
|
||||
});
|
||||
}, [
|
||||
controller,
|
||||
client,
|
||||
agentId,
|
||||
isConnected,
|
||||
isAgentDataReady,
|
||||
isScreenFocused,
|
||||
isAppVisible,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
focusRequest,
|
||||
]);
|
||||
|
||||
return {
|
||||
clearOnInputFocus: useCallback(() => {
|
||||
clearAttention("input-focus");
|
||||
}, [clearAttention]),
|
||||
clearOnPromptSend: useCallback(() => {
|
||||
clearAttention("prompt-send");
|
||||
}, [clearAttention]),
|
||||
clearOnAgentBlur: useCallback(() => {
|
||||
clearAttention("agent-blur");
|
||||
}, [clearAttention]),
|
||||
clearOnInputFocus: controller.focus,
|
||||
clearOnPromptSend: controller.focus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import type { AutocompleteOption, AutocompleteStatus } from "@/components/ui/autocomplete";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
import {
|
||||
useAutocompleteCommands,
|
||||
type AgentSlashCommand,
|
||||
@@ -13,6 +14,9 @@ import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands";
|
||||
import { mergeSlashCommandSources } from "@/plugins/client-slash-commands/model";
|
||||
@@ -70,10 +74,8 @@ interface AgentAutocompleteResult {
|
||||
isVisible: boolean;
|
||||
options: AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
loadingText: string;
|
||||
emptyText: string;
|
||||
/** Shown in place of options; set exactly when `options` is empty. */
|
||||
status: AutocompleteStatus | null;
|
||||
onSelectOption: (option: AutocompleteOption, input?: AgentAutocompleteInputSnapshot) => void;
|
||||
onKeyPress: (event: AgentAutocompleteKeyPressEvent) => boolean;
|
||||
}
|
||||
@@ -162,39 +164,69 @@ function mapDirectorySuggestionsToEntries(payload: {
|
||||
}));
|
||||
}
|
||||
|
||||
function mapCommandToOption(entry: AvailableCommand, t: TFunction): AgentAutocompleteOption {
|
||||
const CLIENT_COMMAND_ICONS = {
|
||||
"archive-agent": "archive",
|
||||
"replace-agent-with-draft": "new-draft",
|
||||
} as const satisfies Record<ClientSlashCommand["kind"], AutocompleteOption["icon"]>;
|
||||
|
||||
interface CommandOptionContext {
|
||||
query: string;
|
||||
pluginNames: ReadonlyMap<string, string>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function mapCommandToOption(
|
||||
entry: AvailableCommand,
|
||||
context: CommandOptionContext,
|
||||
): AgentAutocompleteOption {
|
||||
const command = entry.command;
|
||||
const base = {
|
||||
id: command.name,
|
||||
label: `/${command.name}`,
|
||||
detail: command.argumentHint || undefined,
|
||||
description:
|
||||
entry.source === "client" ? t(entry.command.descriptionKey) : entry.command.description,
|
||||
kind: "command" as const,
|
||||
// The label carries a leading slash the query does not.
|
||||
labelMatches: findHighlightRanges(context.query, command.name).map((range) => ({
|
||||
start: range.start + 1,
|
||||
length: range.length,
|
||||
})),
|
||||
argumentHint: command.argumentHint || undefined,
|
||||
};
|
||||
if (entry.source === "client") {
|
||||
return {
|
||||
...base,
|
||||
type: "client_command",
|
||||
command: entry.command,
|
||||
icon: CLIENT_COMMAND_ICONS[entry.command.kind],
|
||||
description: context.t(entry.command.descriptionKey),
|
||||
tag: "Paseo",
|
||||
};
|
||||
}
|
||||
if (entry.source === "plugin") {
|
||||
return { ...base, type: "plugin_command", command: entry.command };
|
||||
return {
|
||||
...base,
|
||||
type: "plugin_command",
|
||||
command: entry.command,
|
||||
icon: "plugin",
|
||||
description: entry.command.description,
|
||||
tag: context.pluginNames.get(entry.command.pluginId) ?? entry.command.pluginId,
|
||||
};
|
||||
}
|
||||
const isSkill = entry.command.kind === "skill";
|
||||
return {
|
||||
...base,
|
||||
type: "provider_command",
|
||||
icon: isSkill ? "skill" : "command",
|
||||
description: entry.command.description,
|
||||
tag: isSkill ? context.t("agentAutocomplete.skillTag") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
type AutocompleteMode = "command" | "file" | null;
|
||||
|
||||
interface BuildAutocompleteOptionsInput {
|
||||
isVisible: boolean;
|
||||
mode: AutocompleteMode;
|
||||
commands: AgentSlashCommand[];
|
||||
pluginCommands: readonly PluginClientSlashCommand[];
|
||||
pluginNames: ReadonlyMap<string, string>;
|
||||
isDraftContext: boolean;
|
||||
commandFilterQuery: string;
|
||||
activeSlashCommand: SlashCommandRange | null;
|
||||
@@ -203,11 +235,7 @@ interface BuildAutocompleteOptionsInput {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
||||
if (!input.isVisible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildAutocompleteOptions(input: BuildAutocompleteOptionsInput): AgentAutocompleteOption[] {
|
||||
if (input.mode === "command") {
|
||||
const providerCommands = input.commands.map((command) => ({
|
||||
source: "provider" as const,
|
||||
@@ -236,8 +264,12 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
||||
availableCommands,
|
||||
input.commandFilterQuery,
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map((entry) => mapCommandToOption(entry, input.t));
|
||||
const context = {
|
||||
query: input.commandFilterQuery,
|
||||
pluginNames: input.pluginNames,
|
||||
t: input.t,
|
||||
};
|
||||
return orderAutocompleteOptions(matches).map((entry) => mapCommandToOption(entry, context));
|
||||
}
|
||||
|
||||
const activeFileMention = input.activeFileMention;
|
||||
@@ -247,7 +279,7 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
||||
type: "workspace_entry" as const,
|
||||
id: `${entry.kind}:${entry.path}`,
|
||||
label: entry.path,
|
||||
kind: entry.kind,
|
||||
icon: entry.kind,
|
||||
entryPath: entry.path,
|
||||
mention: activeFileMention,
|
||||
}));
|
||||
@@ -295,42 +327,140 @@ function resolveCanLoadCommands(args: {
|
||||
return Boolean(args.agentId) || args.isDraftContext;
|
||||
}
|
||||
|
||||
function resolveAutocompleteIsLoading(args: {
|
||||
mode: AutocompleteMode;
|
||||
isCommandsLoading: boolean;
|
||||
fileSuggestionsIsPending: boolean;
|
||||
fileSuggestionsIsLoading: boolean;
|
||||
optionsLength: number;
|
||||
}): boolean {
|
||||
if (args.mode === "command") {
|
||||
return args.isCommandsLoading && args.optionsLength === 0;
|
||||
}
|
||||
if (args.mode === "file") {
|
||||
return (
|
||||
args.fileSuggestionsIsPending || (args.fileSuggestionsIsLoading && args.optionsLength === 0)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
type CommandsState = "ready" | "loading" | "error";
|
||||
|
||||
function resolveCommandsState(input: {
|
||||
canListCommands: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}): CommandsState {
|
||||
if (!input.canListCommands) return "ready";
|
||||
if (input.isError) return "error";
|
||||
if (input.isLoading) return "loading";
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function resolveAutocompleteErrorMessage(args: {
|
||||
mode: AutocompleteMode;
|
||||
isCommandError: boolean;
|
||||
commandError: Error | null;
|
||||
fileSuggestionsError: unknown;
|
||||
interface AutocompleteMenu {
|
||||
options: AgentAutocompleteOption[];
|
||||
status: AutocompleteStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One state for the open menu, so options and a loading or error row cannot both claim it:
|
||||
* while a status row shows there are no options for Enter or Tab to pick.
|
||||
*/
|
||||
function resolveCommandMenu(input: {
|
||||
commandsState: CommandsState;
|
||||
options: () => AgentAutocompleteOption[];
|
||||
providerLabel: string | null;
|
||||
error: Error | null;
|
||||
onRetry: () => void;
|
||||
emptyText: string;
|
||||
t: TFunction;
|
||||
}): string | undefined {
|
||||
if (args.mode === "command") {
|
||||
return args.isCommandError
|
||||
? (args.commandError?.message ?? args.t("agentAutocomplete.failedToLoad"))
|
||||
: undefined;
|
||||
}): AutocompleteMenu {
|
||||
const { t } = input;
|
||||
if (input.commandsState === "loading") {
|
||||
const message = input.providerLabel
|
||||
? t("agentAutocomplete.loadingProviderCommands", { provider: input.providerLabel })
|
||||
: t("agentAutocomplete.loadingCommands");
|
||||
return { options: [], status: { kind: "loading", message } };
|
||||
}
|
||||
if (args.mode === "file") {
|
||||
return args.fileSuggestionsError instanceof Error
|
||||
? args.fileSuggestionsError.message
|
||||
: undefined;
|
||||
if (input.commandsState === "error") {
|
||||
const title = input.providerLabel
|
||||
? t("agentAutocomplete.providerCommandsFailed", { provider: input.providerLabel })
|
||||
: t("agentAutocomplete.commandsFailed");
|
||||
return {
|
||||
options: [],
|
||||
status: { kind: "error", title, reason: input.error?.message, onRetry: input.onRetry },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
return withEmptyStatus(input.options(), input.emptyText);
|
||||
}
|
||||
|
||||
function resolveFileMenu(input: {
|
||||
options: () => AgentAutocompleteOption[];
|
||||
isPending: boolean;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
t: TFunction;
|
||||
}): AutocompleteMenu {
|
||||
const options = input.options();
|
||||
if (input.isPending || (input.isLoading && options.length === 0)) {
|
||||
return {
|
||||
options: [],
|
||||
status: { kind: "loading", message: input.t("agentAutocomplete.searchingWorkspace") },
|
||||
};
|
||||
}
|
||||
if (input.error instanceof Error) {
|
||||
return {
|
||||
options: [],
|
||||
status: {
|
||||
kind: "error",
|
||||
title: input.t("agentAutocomplete.filesFailed"),
|
||||
reason: input.error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
return withEmptyStatus(options, input.t("agentAutocomplete.noFiles"));
|
||||
}
|
||||
|
||||
function withEmptyStatus(options: AgentAutocompleteOption[], emptyText: string): AutocompleteMenu {
|
||||
return options.length > 0
|
||||
? { options, status: null }
|
||||
: { options, status: { kind: "empty", message: emptyText } };
|
||||
}
|
||||
|
||||
/** How long a command menu waits for commands before it opens on a loading row. */
|
||||
const COMMANDS_LOADING_REVEAL_MS = 300;
|
||||
|
||||
/**
|
||||
* Whether the open command menu has waited long enough to show a loading row. Commands that
|
||||
* arrive sooner paint the list as the first frame; once the wait is over, loading (a retry)
|
||||
* shows at once so the open menu never closes on itself.
|
||||
*/
|
||||
function useLoadingRevealed(isMenuOpen: boolean): boolean {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isMenuOpen) {
|
||||
setRevealed(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setRevealed(true), COMMANDS_LOADING_REVEAL_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isMenuOpen]);
|
||||
return revealed;
|
||||
}
|
||||
|
||||
/** The names command rows and status rows show: the provider's label and each plugin's name. */
|
||||
function useCommandSourceNames(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
draftConfig: DraftCommandConfig | undefined;
|
||||
cwd: string;
|
||||
isCommandMenuOpen: boolean;
|
||||
commandsState: CommandsState;
|
||||
pluginCommandCount: number;
|
||||
}): { providerLabel: string | null; pluginNames: ReadonlyMap<string, string> } {
|
||||
const agentProvider = useSessionStore(
|
||||
(state) => state.sessions[input.serverId]?.agents?.get(input.agentId)?.provider,
|
||||
);
|
||||
const provider = input.draftConfig?.provider ?? agentProvider;
|
||||
// Only a loading or error row names the provider; only plugin rows need plugin names.
|
||||
const { entries: providerEntries } = useProvidersSnapshot(input.serverId, {
|
||||
cwd: input.cwd,
|
||||
enabled: input.isCommandMenuOpen && input.commandsState !== "ready",
|
||||
});
|
||||
const pluginList = usePluginList(input.serverId, {
|
||||
enabled: input.isCommandMenuOpen && input.pluginCommandCount > 0,
|
||||
});
|
||||
const pluginNames = useMemo(
|
||||
() => new Map((pluginList.data ?? []).map((plugin) => [plugin.id, pluginDisplayName(plugin)])),
|
||||
[pluginList.data],
|
||||
);
|
||||
return {
|
||||
providerLabel: provider ? resolveProviderLabel(provider, providerEntries) : null,
|
||||
pluginNames,
|
||||
};
|
||||
}
|
||||
|
||||
// A draft's agentId is a draft key the daemon does not know, so a draft lists
|
||||
@@ -345,14 +475,10 @@ function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAutocompleteEmptyText(args: {
|
||||
mode: AutocompleteMode;
|
||||
function resolveCommandsEmptyText(args: {
|
||||
draft: DraftCommandTarget | undefined;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (args.mode === "file") {
|
||||
return args.t("agentAutocomplete.noFiles");
|
||||
}
|
||||
if (args.draft?.status === "needs-project") {
|
||||
return args.t("agentAutocomplete.chooseProjectForCommands");
|
||||
}
|
||||
@@ -438,10 +564,25 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
||||
draftConfig: queryDraftConfig,
|
||||
});
|
||||
const { commands, isError, error } = commandsQuery;
|
||||
const isCommandsLoading = canListCommands && commandsQuery.isLoading;
|
||||
|
||||
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
|
||||
const commandsState = resolveCommandsState({
|
||||
canListCommands,
|
||||
isLoading: commandsQuery.isLoading,
|
||||
isError: commandsQuery.isError,
|
||||
});
|
||||
const isCommandMenuOpen = mode === "command" && canShowAutocomplete;
|
||||
const isLoadingRevealed = useLoadingRevealed(isCommandMenuOpen);
|
||||
const isVisible =
|
||||
canShowAutocomplete &&
|
||||
!(isCommandMenuOpen && commandsState === "loading" && !isLoadingRevealed);
|
||||
const { providerLabel, pluginNames } = useCommandSourceNames({
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig: queryDraftConfig,
|
||||
cwd: autocompleteCwd,
|
||||
isCommandMenuOpen,
|
||||
commandsState,
|
||||
pluginCommandCount: pluginClientSlashCommands.length,
|
||||
});
|
||||
|
||||
const fileSuggestionsQuery = useQuery({
|
||||
queryKey: [
|
||||
@@ -479,33 +620,67 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const options = useMemo<AgentAutocompleteOption[]>(
|
||||
() =>
|
||||
buildCommandAutocompleteOptions({
|
||||
const commandsEmptyText = resolveCommandsEmptyText({ draft, t });
|
||||
const retryCommandsQuery = commandsQuery.retry;
|
||||
const retryCommands = useCallback(() => {
|
||||
retryCommandsQuery();
|
||||
// Pressing Retry takes focus on web; hand it back so the user keeps typing the command.
|
||||
onAutocompleteApplied?.();
|
||||
}, [onAutocompleteApplied, retryCommandsQuery]);
|
||||
const { options, status } = useMemo<AutocompleteMenu>(() => {
|
||||
if (!isVisible) return { options: [], status: null };
|
||||
const buildOptions = () =>
|
||||
buildAutocompleteOptions({
|
||||
activeFileMention,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
commands: commandsQuery.commands,
|
||||
pluginCommands: pluginClientSlashCommands,
|
||||
pluginNames,
|
||||
activeSlashCommand,
|
||||
fileSuggestions: fileSuggestionsQuery.data ?? [],
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
t,
|
||||
}),
|
||||
[
|
||||
activeFileMention,
|
||||
activeSlashCommand,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
pluginClientSlashCommands,
|
||||
fileSuggestionsQuery.data,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
});
|
||||
if (mode === "file") {
|
||||
return resolveFileMenu({
|
||||
options: buildOptions,
|
||||
isPending: fileSuggestionsQuery.isPending,
|
||||
isLoading: fileSuggestionsQuery.isLoading,
|
||||
error: fileSuggestionsQuery.error,
|
||||
t,
|
||||
});
|
||||
}
|
||||
return resolveCommandMenu({
|
||||
commandsState,
|
||||
options: buildOptions,
|
||||
providerLabel,
|
||||
error: commandsQuery.error,
|
||||
onRetry: retryCommands,
|
||||
emptyText: commandsEmptyText,
|
||||
t,
|
||||
],
|
||||
);
|
||||
});
|
||||
}, [
|
||||
activeFileMention,
|
||||
activeSlashCommand,
|
||||
commandFilterQuery,
|
||||
commandsEmptyText,
|
||||
commandsQuery.commands,
|
||||
commandsQuery.error,
|
||||
commandsState,
|
||||
fileSuggestionsQuery.data,
|
||||
fileSuggestionsQuery.error,
|
||||
fileSuggestionsQuery.isLoading,
|
||||
fileSuggestionsQuery.isPending,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
pluginClientSlashCommands,
|
||||
pluginNames,
|
||||
providerLabel,
|
||||
retryCommands,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(option: AutocompleteOption, snapshot?: AgentAutocompleteInputSnapshot) => {
|
||||
@@ -587,35 +762,11 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const isLoading = resolveAutocompleteIsLoading({
|
||||
mode,
|
||||
isCommandsLoading,
|
||||
fileSuggestionsIsPending: fileSuggestionsQuery.isPending,
|
||||
fileSuggestionsIsLoading: fileSuggestionsQuery.isLoading,
|
||||
optionsLength: options.length,
|
||||
});
|
||||
const errorMessage = resolveAutocompleteErrorMessage({
|
||||
mode,
|
||||
isCommandError: isError,
|
||||
commandError: error,
|
||||
fileSuggestionsError: fileSuggestionsQuery.error,
|
||||
t,
|
||||
});
|
||||
|
||||
const loadingText =
|
||||
mode === "file"
|
||||
? t("agentAutocomplete.searchingWorkspace")
|
||||
: t("agentAutocomplete.loadingCommands");
|
||||
const emptyText = resolveAutocompleteEmptyText({ mode, draft, t });
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
options,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
loadingText,
|
||||
emptyText,
|
||||
status,
|
||||
onSelectOption,
|
||||
onKeyPress,
|
||||
};
|
||||
|
||||
@@ -85,6 +85,7 @@ export function useAggregatedAgents(options?: {
|
||||
serverLabel,
|
||||
title: agent.title ?? null,
|
||||
status: agent.status,
|
||||
backgroundWork: agent.backgroundWork,
|
||||
turn: agent.turn,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
getAutocompleteFallbackIndex,
|
||||
getAutocompleteNextIndex,
|
||||
@@ -31,38 +31,22 @@ export function useAutocomplete<
|
||||
TOption,
|
||||
TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent,
|
||||
>(input: UseAutocompleteInput<TOption, TKeyPressEvent>): UseAutocompleteResult<TKeyPressEvent> {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const previousQueryRef = useRef("");
|
||||
// The user's choice, tied to the query it was made for. The shown index is resolved from it
|
||||
// during render, so a list never paints its first frame without a selection: the popover only
|
||||
// measures once a non-empty list has one.
|
||||
const [choice, setChoice] = useState<{ index: number; query: string } | null>(null);
|
||||
const itemCount = input.isVisible ? input.options.length : 0;
|
||||
const selectedIndex = resolveSelectedIndex({
|
||||
choice,
|
||||
query: input.query,
|
||||
itemCount,
|
||||
optionsPosition: input.optionsPosition,
|
||||
});
|
||||
|
||||
// A choice belongs to one search. Until this clears it, the query check above ignores it.
|
||||
useEffect(() => {
|
||||
if (!input.isVisible) {
|
||||
previousQueryRef.current = input.query;
|
||||
setSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const queryChanged = previousQueryRef.current !== input.query;
|
||||
previousQueryRef.current = input.query;
|
||||
|
||||
setSelectedIndex((current) => {
|
||||
if (input.options.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const fallbackIndex = getAutocompleteFallbackIndex(
|
||||
input.options.length,
|
||||
input.optionsPosition,
|
||||
);
|
||||
|
||||
if (queryChanged) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
if (current < 0 || current >= input.options.length) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, [input.isVisible, input.options.length, input.query, input.optionsPosition]);
|
||||
setChoice(null);
|
||||
}, [input.isVisible, input.query]);
|
||||
|
||||
const onKeyPress = useCallback(
|
||||
(event: TKeyPressEvent) => {
|
||||
@@ -70,41 +54,22 @@ export function useAutocomplete<
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) =>
|
||||
getAutocompleteNextIndex({
|
||||
currentIndex: current,
|
||||
setChoice({
|
||||
index: getAutocompleteNextIndex({
|
||||
currentIndex: selectedIndex,
|
||||
itemCount: input.options.length,
|
||||
key: "ArrowUp",
|
||||
key: event.key,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) =>
|
||||
getAutocompleteNextIndex({
|
||||
currentIndex: current,
|
||||
itemCount: input.options.length,
|
||||
key: "ArrowDown",
|
||||
}),
|
||||
);
|
||||
query: input.query,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Tab" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const fallbackIndex = getAutocompleteFallbackIndex(
|
||||
input.options.length,
|
||||
input.optionsPosition,
|
||||
);
|
||||
const resolvedIndex =
|
||||
selectedIndex >= 0 && selectedIndex < input.options.length
|
||||
? selectedIndex
|
||||
: fallbackIndex;
|
||||
const selectedOption = input.options[resolvedIndex];
|
||||
const selectedOption = input.options[selectedIndex];
|
||||
if (selectedOption) {
|
||||
input.onSelectOption(selectedOption, event);
|
||||
}
|
||||
@@ -127,3 +92,21 @@ export function useAutocomplete<
|
||||
onKeyPress,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSelectedIndex(input: {
|
||||
choice: { index: number; query: string } | null;
|
||||
query: string;
|
||||
itemCount: number;
|
||||
optionsPosition?: AutocompleteOptionsPosition;
|
||||
}): number {
|
||||
const { choice } = input;
|
||||
if (
|
||||
choice &&
|
||||
choice.query === input.query &&
|
||||
choice.index >= 0 &&
|
||||
choice.index < input.itemCount
|
||||
) {
|
||||
return choice.index;
|
||||
}
|
||||
return getAutocompleteFallbackIndex(input.itemCount, input.optionsPosition);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ interface UseBranchSwitcherInput {
|
||||
|
||||
interface UseBranchSwitcherResult {
|
||||
branchOptions: ComboboxOption[];
|
||||
isLoadingBranches: boolean;
|
||||
isFetchingBranches: boolean;
|
||||
branchError: Error | null;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
handleBranchSelect: (branchId: string) => void;
|
||||
@@ -185,5 +188,14 @@ export function useBranchSwitcher({
|
||||
],
|
||||
);
|
||||
|
||||
return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout };
|
||||
return {
|
||||
branchOptions,
|
||||
isLoadingBranches: branchSuggestionsQuery.isPending,
|
||||
isFetchingBranches: branchSuggestionsQuery.isFetching,
|
||||
branchError: branchSuggestionsQuery.error,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
handleBranchSelect,
|
||||
invalidateStashAndCheckout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -820,7 +820,7 @@ describe("translation resources", () => {
|
||||
expect(en.branchSwitcher.stashRestored).toBe("Stashed changes restored");
|
||||
expect(en.agentAutocomplete.searchingWorkspace).toBe("Searching workspace...");
|
||||
expect(en.agentAutocomplete.noCommands).toBe("No commands found");
|
||||
expect(en.agentAutocomplete.failedToLoad).toBe("Failed to load");
|
||||
expect(en.agentAutocomplete.commandsFailed).toBe("Couldn't load commands");
|
||||
expect(en.loadOlderHistory.failed).toBe("Couldn't load older history");
|
||||
expect(en.agentControls.thinking.extraHigh).toBe("Extra high");
|
||||
expect(en.agentControls.model.unknown).toBe("Unknown model");
|
||||
|
||||
@@ -379,6 +379,24 @@ export const ar: TranslationResources = {
|
||||
answerPlaceholder: "اكتب إجابتك...",
|
||||
otherPlaceholder: "آخر...",
|
||||
},
|
||||
backgroundWork: {
|
||||
pillOne: "مهمة واحدة في الخلفية",
|
||||
pillMany: "{{count}} مهمة في الخلفية",
|
||||
notRunning: "لم يعد قيد التشغيل",
|
||||
dismiss: "إغلاق",
|
||||
stop: "إيقاف",
|
||||
stopping: "جارٍ الإيقاف…",
|
||||
stopError: "تعذر تأكيد الإيقاف.",
|
||||
label: "في الخلفية",
|
||||
title: "عمل الخلفية",
|
||||
waiting: "بانتظار تفاصيل عمل الخلفية",
|
||||
error: "تعذر تحميل عمل الخلفية",
|
||||
retry: "إعادة المحاولة",
|
||||
running: "عمل الخلفية قيد التشغيل",
|
||||
shell: "الصدفة",
|
||||
monitor: "المراقبة",
|
||||
other: "عمل آخر",
|
||||
},
|
||||
todo: {
|
||||
title: "المهام",
|
||||
empty: "لا توجد مهام حتى الآن.",
|
||||
@@ -1161,6 +1179,14 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "تعذر تحميل عرض الشريط الجانبي.",
|
||||
useDefault: "استخدام العرض الافتراضي",
|
||||
readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.",
|
||||
agentUnavailable: "لم يعد الوكيل متاحًا",
|
||||
terminalUnavailable: "لم تعد الطرفية متاحة",
|
||||
projectUnavailable: "لم يعد المشروع متاحًا",
|
||||
},
|
||||
statusGroupAccessibility: "مجموعة {{label}}",
|
||||
statusBucket: {
|
||||
needsInput: "تحتاج إدخالاً",
|
||||
@@ -1601,6 +1627,7 @@ export const ar: TranslationResources = {
|
||||
openPath: "فتح المسار",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "تعذّر تحميل الفروع. أعد فتح قائمة الفروع للمحاولة مرة أخرى.",
|
||||
triggerTooltip: "تبديل فرع مساحة العمل",
|
||||
currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.",
|
||||
placeholder: "تبديل الفرع...",
|
||||
@@ -1623,7 +1650,11 @@ export const ar: TranslationResources = {
|
||||
loadingCommands: "جارٍ تحميل الأوامر...",
|
||||
noFiles: "لم يتم العثور على ملفات أو أدلة",
|
||||
noCommands: "لم يتم العثور على أي أوامر",
|
||||
failedToLoad: "فشل التحميل",
|
||||
loadingProviderCommands: "جارٍ تحميل أوامر {{provider}}...",
|
||||
commandsFailed: "تعذر تحميل الأوامر",
|
||||
providerCommandsFailed: "تعذر تحميل أوامر {{provider}}",
|
||||
filesFailed: "تعذر البحث في الملفات",
|
||||
skillTag: "مهارة",
|
||||
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
|
||||
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
|
||||
},
|
||||
@@ -2584,6 +2615,12 @@ export const ar: TranslationResources = {
|
||||
description: "عرض مخطط للتنقل بين المطالبات",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "عرض الشريط الجانبي",
|
||||
label: "العرض",
|
||||
default: "العرض الافتراضي",
|
||||
unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.",
|
||||
},
|
||||
header: {
|
||||
title: "الرأس",
|
||||
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
|
||||
|
||||
@@ -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.",
|
||||
@@ -1169,6 +1187,14 @@ export const en = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "Sidebar view failed to load.",
|
||||
useDefault: "Use default view",
|
||||
readFailed: "Could not load your sidebar view preference.",
|
||||
agentUnavailable: "Agent is no longer available",
|
||||
terminalUnavailable: "Terminal is no longer available",
|
||||
projectUnavailable: "Project is no longer available",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} group",
|
||||
statusBucket: {
|
||||
needsInput: "Needs input",
|
||||
@@ -1631,6 +1657,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 +1676,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",
|
||||
},
|
||||
@@ -2675,6 +2706,12 @@ export const en = {
|
||||
description: "Show an outline for jumping between prompts",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Sidebar view",
|
||||
label: "View",
|
||||
default: "Default view",
|
||||
unavailable: "The selected view is unavailable or disabled. Showing Default view.",
|
||||
},
|
||||
header: {
|
||||
title: "Header",
|
||||
description: "Choose which items appear at the top of the sidebar and in what order",
|
||||
|
||||
@@ -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.",
|
||||
@@ -1197,6 +1215,14 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "No se pudo cargar la vista de la barra lateral.",
|
||||
useDefault: "Usar vista predeterminada",
|
||||
readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.",
|
||||
agentUnavailable: "El agente ya no está disponible",
|
||||
terminalUnavailable: "La terminal ya no está disponible",
|
||||
projectUnavailable: "El proyecto ya no está disponible",
|
||||
},
|
||||
statusGroupAccessibility: "Grupo {{label}}",
|
||||
statusBucket: {
|
||||
needsInput: "Necesita datos",
|
||||
@@ -1647,6 +1673,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 +1697,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",
|
||||
},
|
||||
@@ -2604,6 +2635,13 @@ export const es: TranslationResources = {
|
||||
description: "Muestra un esquema para saltar entre instrucciones",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Vista de la barra lateral",
|
||||
label: "Vista",
|
||||
default: "Vista predeterminada",
|
||||
unavailable:
|
||||
"La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.",
|
||||
},
|
||||
header: {
|
||||
title: "Encabezado",
|
||||
description:
|
||||
|
||||
@@ -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.",
|
||||
@@ -1190,6 +1208,14 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "Impossible de charger la vue de la barre latérale.",
|
||||
useDefault: "Utiliser la vue par défaut",
|
||||
readFailed: "Impossible de charger votre préférence de vue de la barre latérale.",
|
||||
agentUnavailable: "Cet agent n’est plus disponible",
|
||||
terminalUnavailable: "Ce terminal n’est plus disponible",
|
||||
projectUnavailable: "Ce projet n’est plus disponible",
|
||||
},
|
||||
statusGroupAccessibility: "Groupe {{label}}",
|
||||
statusBucket: {
|
||||
needsInput: "Attend une réponse",
|
||||
@@ -1643,6 +1669,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 +1694,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",
|
||||
},
|
||||
@@ -2605,6 +2636,13 @@ export const fr: TranslationResources = {
|
||||
description: "Afficher un plan pour naviguer entre les prompts",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Vue de la barre latérale",
|
||||
label: "Vue",
|
||||
default: "Vue par défaut",
|
||||
unavailable:
|
||||
"La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.",
|
||||
},
|
||||
header: {
|
||||
title: "En-tête",
|
||||
description:
|
||||
|
||||
@@ -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: "タスクがまだありません。",
|
||||
@@ -1174,6 +1192,14 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "サイドバーのビューを読み込めませんでした。",
|
||||
useDefault: "デフォルトのビューを使用",
|
||||
readFailed: "サイドバーのビュー設定を読み込めませんでした。",
|
||||
agentUnavailable: "このエージェントは利用できなくなりました",
|
||||
terminalUnavailable: "このターミナルは利用できなくなりました",
|
||||
projectUnavailable: "このプロジェクトは利用できなくなりました",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} グループ",
|
||||
statusBucket: {
|
||||
needsInput: "入力待ち",
|
||||
@@ -1618,6 +1644,7 @@ export const ja: TranslationResources = {
|
||||
openPath: "パスを開く",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "ブランチを読み込めませんでした。選択画面を開き直してお試しください。",
|
||||
triggerTooltip: "ワークスペースのブランチを切り替え",
|
||||
currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。",
|
||||
placeholder: "ブランチを切り替え...",
|
||||
@@ -1641,7 +1668,11 @@ export const ja: TranslationResources = {
|
||||
loadingCommands: "コマンドを読み込み中...",
|
||||
noFiles: "ファイルまたはディレクトリが見つかりません",
|
||||
noCommands: "コマンドが見つかりません",
|
||||
failedToLoad: "読み込みに失敗しました",
|
||||
loadingProviderCommands: "{{provider}} のコマンドを読み込み中...",
|
||||
commandsFailed: "コマンドを読み込めませんでした",
|
||||
providerCommandsFailed: "{{provider}} のコマンドを読み込めませんでした",
|
||||
filesFailed: "ファイルを検索できませんでした",
|
||||
skillTag: "スキル",
|
||||
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
|
||||
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
|
||||
},
|
||||
@@ -2571,6 +2602,13 @@ export const ja: TranslationResources = {
|
||||
description: "プロンプト間を移動するためのアウトラインを表示します",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "サイドバーのビュー",
|
||||
label: "ビュー",
|
||||
default: "デフォルトのビュー",
|
||||
unavailable:
|
||||
"選択したビューは利用できないか無効です。デフォルトのビューを表示しています。",
|
||||
},
|
||||
header: {
|
||||
title: "ヘッダー",
|
||||
description: "サイドバー上部に表示する項目とその順序を選択します",
|
||||
|
||||
@@ -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: "아직 작업이 없습니다.",
|
||||
@@ -1168,6 +1186,14 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "사이드바 보기를 불러오지 못했습니다.",
|
||||
useDefault: "기본 보기 사용",
|
||||
readFailed: "사이드바 보기 설정을 불러오지 못했습니다.",
|
||||
agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다",
|
||||
terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다",
|
||||
projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} 그룹",
|
||||
statusBucket: {
|
||||
needsInput: "입력 필요",
|
||||
@@ -1611,6 +1637,7 @@ export const ko: TranslationResources = {
|
||||
openPath: "경로 열기",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "브랜치를 불러오지 못했습니다. 선택기를 다시 열어 재시도하세요.",
|
||||
triggerTooltip: "워크스페이스 브랜치 전환",
|
||||
currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.",
|
||||
placeholder: "브랜치 전환...",
|
||||
@@ -1634,7 +1661,11 @@ export const ko: TranslationResources = {
|
||||
loadingCommands: "명령 불러오는 중...",
|
||||
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
|
||||
noCommands: "명령을 찾을 수 없습니다",
|
||||
failedToLoad: "불러오지 못했습니다",
|
||||
loadingProviderCommands: "{{provider}} 명령 불러오는 중...",
|
||||
commandsFailed: "명령을 불러오지 못했습니다",
|
||||
providerCommandsFailed: "{{provider}} 명령을 불러오지 못했습니다",
|
||||
filesFailed: "파일을 검색하지 못했습니다",
|
||||
skillTag: "스킬",
|
||||
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
|
||||
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
|
||||
},
|
||||
@@ -2562,6 +2593,13 @@ export const ko: TranslationResources = {
|
||||
description: "프롬프트 사이를 이동하기 위한 개요 표시",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "사이드바 보기",
|
||||
label: "보기",
|
||||
default: "기본 보기",
|
||||
unavailable:
|
||||
"선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.",
|
||||
},
|
||||
header: {
|
||||
title: "헤더",
|
||||
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
|
||||
|
||||
@@ -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.",
|
||||
@@ -1187,6 +1205,14 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "Não foi possível carregar a visualização da barra lateral.",
|
||||
useDefault: "Usar visualização padrão",
|
||||
readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.",
|
||||
agentUnavailable: "O agente não está mais disponível",
|
||||
terminalUnavailable: "O terminal não está mais disponível",
|
||||
projectUnavailable: "O projeto não está mais disponível",
|
||||
},
|
||||
statusGroupAccessibility: "Grupo {{label}}",
|
||||
statusBucket: {
|
||||
needsInput: "Precisa de resposta",
|
||||
@@ -1632,6 +1658,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 +1682,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",
|
||||
},
|
||||
@@ -2586,6 +2617,13 @@ export const ptBR: TranslationResources = {
|
||||
description: "Mostrar uma estrutura para navegar entre prompts",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Visualização da barra lateral",
|
||||
label: "Visualização",
|
||||
default: "Visualização padrão",
|
||||
unavailable:
|
||||
"A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.",
|
||||
},
|
||||
header: {
|
||||
title: "Cabeçalho",
|
||||
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
|
||||
|
||||
@@ -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: "Задач пока нет.",
|
||||
@@ -1178,6 +1196,14 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "Не удалось загрузить вид боковой панели.",
|
||||
useDefault: "Использовать стандартный вид",
|
||||
readFailed: "Не удалось загрузить настройку вида боковой панели.",
|
||||
agentUnavailable: "Агент больше недоступен",
|
||||
terminalUnavailable: "Терминал больше недоступен",
|
||||
projectUnavailable: "Проект больше недоступен",
|
||||
},
|
||||
statusGroupAccessibility: "Группа «{{label}}»",
|
||||
statusBucket: {
|
||||
needsInput: "Ожидает ввода",
|
||||
@@ -1629,6 +1655,7 @@ export const ru: TranslationResources = {
|
||||
openPath: "Открыть путь",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "Не удалось загрузить ветки. Откройте список заново, чтобы повторить попытку.",
|
||||
triggerTooltip: "Переключить ветку рабочего пространства",
|
||||
currentBranch: "Текущая ветка: {{branchName}}. Нажмите, чтобы переключить ветку.",
|
||||
placeholder: "Сменить ветку...",
|
||||
@@ -1653,7 +1680,11 @@ export const ru: TranslationResources = {
|
||||
loadingCommands: "Загрузка команд...",
|
||||
noFiles: "Файлы и каталоги не найдены",
|
||||
noCommands: "Команды не найдены",
|
||||
failedToLoad: "Не удалось загрузить",
|
||||
loadingProviderCommands: "Загрузка команд {{provider}}...",
|
||||
commandsFailed: "Не удалось загрузить команды",
|
||||
providerCommandsFailed: "Не удалось загрузить команды {{provider}}",
|
||||
filesFailed: "Не удалось найти файлы",
|
||||
skillTag: "Навык",
|
||||
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
|
||||
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
|
||||
},
|
||||
@@ -2592,6 +2623,12 @@ export const ru: TranslationResources = {
|
||||
description: "Показывать оглавление для перехода между запросами",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Вид боковой панели",
|
||||
label: "Вид",
|
||||
default: "Стандартный вид",
|
||||
unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.",
|
||||
},
|
||||
header: {
|
||||
title: "Верх",
|
||||
description:
|
||||
|
||||
@@ -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: "还没有任务。",
|
||||
@@ -1153,6 +1171,14 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "无法加载侧边栏视图。",
|
||||
useDefault: "使用默认视图",
|
||||
readFailed: "无法加载侧边栏视图偏好设置。",
|
||||
agentUnavailable: "此智能体已不可用",
|
||||
terminalUnavailable: "此终端已不可用",
|
||||
projectUnavailable: "此项目已不可用",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} 分组",
|
||||
statusBucket: {
|
||||
needsInput: "需要输入",
|
||||
@@ -1584,6 +1610,7 @@ export const zhCN: TranslationResources = {
|
||||
openPath: "打开路径",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "无法加载分支,请重新打开选择器以重试。",
|
||||
triggerTooltip: "切换工作区分支",
|
||||
currentBranch: "当前分支:{{branchName}}。按下以切换分支。",
|
||||
placeholder: "切换分支...",
|
||||
@@ -1606,7 +1633,11 @@ export const zhCN: TranslationResources = {
|
||||
loadingCommands: "正在加载 commands...",
|
||||
noFiles: "没有找到文件或目录",
|
||||
noCommands: "没有找到 commands",
|
||||
failedToLoad: "加载失败",
|
||||
loadingProviderCommands: "正在加载 {{provider}} commands...",
|
||||
commandsFailed: "无法加载 commands",
|
||||
providerCommandsFailed: "无法加载 {{provider}} commands",
|
||||
filesFailed: "无法搜索文件",
|
||||
skillTag: "Skill",
|
||||
chooseProjectForCommands: "选择项目以查看命令",
|
||||
chooseModelForCommands: "选择模型以查看命令",
|
||||
},
|
||||
@@ -2521,6 +2552,12 @@ export const zhCN: TranslationResources = {
|
||||
description: "显示用于在提示词之间跳转的大纲",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "侧边栏视图",
|
||||
label: "视图",
|
||||
default: "默认视图",
|
||||
unavailable: "所选视图不可用或已禁用。正在显示默认视图。",
|
||||
},
|
||||
header: {
|
||||
title: "顶部",
|
||||
description: "选择侧边栏顶部显示的项目及其顺序",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPluginClientApi } from "./client-state/api";
|
||||
import { callPluginRpc } from "@getpaseo/plugin/client/host";
|
||||
import type {
|
||||
PluginAgentCommandContext,
|
||||
@@ -32,7 +33,7 @@ export function createPluginCapabilities(
|
||||
navigation.openSurface(plugin.id, screenId, params);
|
||||
}
|
||||
return {
|
||||
paseo: plugin.paseo,
|
||||
paseo: getPluginClientApi(plugin).paseo,
|
||||
rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input),
|
||||
openSettings(screenId) {
|
||||
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
|
||||
|
||||
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPluginClientApi } from "./client-state/api";
|
||||
import appPackage from "../../package.json";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { QueryObserver } from "@tanstack/react-query";
|
||||
@@ -139,7 +140,7 @@ function harness() {
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
paseo: getPluginClientApi(installation).paseo,
|
||||
i18n: installation.i18n,
|
||||
rpc: async () => {
|
||||
throw new Error("Unexpected runtime RPC");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createPlayAudio, type AudioEngine } from "@/audio";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import { getPluginClientApi } from "./client-state/api";
|
||||
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
|
||||
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
|
||||
import {
|
||||
@@ -27,7 +26,7 @@ export function createPluginClientRuntime(
|
||||
...capabilities,
|
||||
i18n: installation.i18n,
|
||||
playAudio: createPlayAudio(audio, installation.lifetime.signal),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
|
||||
hosts: getPluginClientApi(installation).hosts,
|
||||
addComposerPill(contribution) {
|
||||
return pluginButtonStore.addComposerPill(installation, contribution);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createPluginReferenceApi } from "@getpaseo/plugin/client/host";
|
||||
import { router } from "expo-router";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { createPluginHosts } from "../hosts";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginClientStateSource } from "./source";
|
||||
|
||||
interface InstallationApi {
|
||||
paseo: ReturnType<typeof createPluginReferenceApi>;
|
||||
hosts: ReturnType<typeof createPluginHosts>;
|
||||
}
|
||||
// Registry presentation clones share the installation lifetime and its client pool.
|
||||
const apis = new WeakMap<AbortSignal, InstallationApi>();
|
||||
|
||||
export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi {
|
||||
const cached = apis.get(plugin.lifetime.signal);
|
||||
if (cached) return cached;
|
||||
const state = createPluginClientStateSource();
|
||||
const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal);
|
||||
const paseo = createPluginReferenceApi({
|
||||
paseo: plugin.paseo,
|
||||
resolveHost: hosts.getPaseoClient,
|
||||
readProject: state.getProject,
|
||||
navigation: {
|
||||
openWorkspace(ref) {
|
||||
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
|
||||
},
|
||||
openAgent(ref) {
|
||||
const agent = state.getAgent(ref);
|
||||
if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable"));
|
||||
navigateToWorkspace({
|
||||
serverId: ref.hostId,
|
||||
workspaceId: agent.workspace.id,
|
||||
target: { kind: "agent", agentId: ref.id },
|
||||
});
|
||||
},
|
||||
openTerminal(ref) {
|
||||
const terminal = state.getTerminal(ref);
|
||||
if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable"));
|
||||
navigateToWorkspace({
|
||||
serverId: ref.hostId,
|
||||
workspaceId: terminal.workspace.id,
|
||||
target: { kind: "terminal", terminalId: ref.id },
|
||||
});
|
||||
},
|
||||
openProject(ref) {
|
||||
const project = state.getProject(ref);
|
||||
if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable"));
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: ref.hostId,
|
||||
projectId: ref.id,
|
||||
sourceDirectory: project.rootPath,
|
||||
displayName: project.displayName,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
const api = { paseo, hosts };
|
||||
apis.set(plugin.lifetime.signal, api);
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import type { PluginConnectedHostsSource } from "./source";
|
||||
|
||||
interface PluginHostsRuntime {
|
||||
getHosts(): readonly { serverId: string }[];
|
||||
getSnapshot(serverId: string): { connectionStatus: string } | null | undefined;
|
||||
subscribeAll(listener: () => void): () => void;
|
||||
subscribeHostList(listener: () => void): () => void;
|
||||
acquireDirectoryDemand(serverId: string): () => void;
|
||||
}
|
||||
|
||||
export function createConnectedPluginHosts(
|
||||
getRuntime: () => PluginHostsRuntime,
|
||||
): PluginConnectedHostsSource {
|
||||
let snapshot: readonly string[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
let release: (() => void) | null = null;
|
||||
|
||||
const source: PluginConnectedHostsSource = {
|
||||
getSnapshot() {
|
||||
const runtime = getRuntime();
|
||||
const next = runtime
|
||||
.getHosts()
|
||||
.filter((host) => runtime.getSnapshot(host.serverId)?.connectionStatus === "online")
|
||||
.map((host) => host.serverId);
|
||||
if (next.length !== snapshot.length || next.some((id, index) => id !== snapshot[index]))
|
||||
snapshot = Object.freeze(next);
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
if (!release) {
|
||||
const runtime = getRuntime();
|
||||
const demands = new Map<string, () => void>();
|
||||
const reconcile = () => {
|
||||
const ids = new Set(source.getSnapshot());
|
||||
for (const [id, releaseDemand] of demands) {
|
||||
if (!ids.has(id)) {
|
||||
demands.delete(id);
|
||||
releaseDemand();
|
||||
}
|
||||
}
|
||||
for (const id of ids) {
|
||||
if (!demands.has(id)) demands.set(id, runtime.acquireDirectoryDemand(id));
|
||||
}
|
||||
};
|
||||
const notify = () => {
|
||||
reconcile();
|
||||
for (const subscriber of listeners) subscriber();
|
||||
};
|
||||
const releaseRuntime = runtime.subscribeAll(notify);
|
||||
const releaseHosts = runtime.subscribeHostList(notify);
|
||||
reconcile();
|
||||
release = () => {
|
||||
releaseRuntime();
|
||||
releaseHosts();
|
||||
for (const releaseDemand of demands.values()) releaseDemand();
|
||||
demands.clear();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0 && release) {
|
||||
release();
|
||||
release = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
return source;
|
||||
}
|
||||
export const connectedPluginHosts = createConnectedPluginHosts(getHostRuntimeStore);
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
export const workspace: WorkspaceDescriptor = {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Paseo",
|
||||
projectRootPath: "/repo/paseo",
|
||||
workspaceDirectory: "/repo/paseo/review",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "Review",
|
||||
title: "Plugin review",
|
||||
pinnedAt: null,
|
||||
status: "running",
|
||||
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
|
||||
archivingAt: null,
|
||||
diffStat: { additions: 12, deletions: 3 },
|
||||
scripts: [],
|
||||
};
|
||||
|
||||
export const agent: Agent = {
|
||||
serverId: "host-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "running",
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: new Date("2026-08-16T10:01:00.000Z"),
|
||||
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: "code",
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: "Implement plugin panels",
|
||||
cwd: "/repo/paseo/review",
|
||||
workspaceId: "workspace-1",
|
||||
model: "gpt-5.6",
|
||||
thinkingOptionId: "high",
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
parentAgentId: null,
|
||||
labels: { phase: "implementation" },
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createEntityRefs } from "@getpaseo/plugin/client/host";
|
||||
|
||||
export const entityRefs = createEntityRefs();
|
||||
@@ -1,66 +1,20 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { fr } from "@/i18n/resources/fr";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
|
||||
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Paseo",
|
||||
projectRootPath: "/repo/paseo",
|
||||
workspaceDirectory: "/repo/paseo/review",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "Review",
|
||||
title: "Plugin review",
|
||||
pinnedAt: null,
|
||||
status: "running",
|
||||
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
|
||||
archivingAt: null,
|
||||
diffStat: { additions: 12, deletions: 3 },
|
||||
scripts: [],
|
||||
};
|
||||
|
||||
const agent: Agent = {
|
||||
serverId: "host-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "running",
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: new Date("2026-08-16T10:01:00.000Z"),
|
||||
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: "code",
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: "Implement plugin panels",
|
||||
cwd: "/repo/paseo/review",
|
||||
workspaceId: "workspace-1",
|
||||
model: "gpt-5.6",
|
||||
thinkingOptionId: "high",
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
parentAgentId: null,
|
||||
labels: { phase: "implementation" },
|
||||
};
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
createPluginAgentSnapshot,
|
||||
createPluginWorkspaceSnapshot,
|
||||
createPluginProjectSnapshot,
|
||||
} from "./snapshots";
|
||||
|
||||
import { workspace, agent } from "./fixtures";
|
||||
describe("plugin context snapshots", () => {
|
||||
it("commits a deeply immutable normalized workspace snapshot synchronously", () => {
|
||||
const snapshot = createPluginWorkspaceSnapshot(workspace);
|
||||
expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot);
|
||||
const snapshot = createPluginWorkspaceSnapshot(workspace, "host-1");
|
||||
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(snapshot);
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
expect(snapshot).toMatchObject({
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Paseo",
|
||||
@@ -90,7 +44,7 @@ describe("plugin context snapshots", () => {
|
||||
const snapshot = createPluginAgentSnapshot(agent, workspace.id);
|
||||
expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot);
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
expect(snapshot).toMatchObject({
|
||||
id: "agent-1",
|
||||
workspaceId: "workspace-1",
|
||||
provider: "codex",
|
||||
@@ -119,3 +73,82 @@ describe("plugin context snapshots", () => {
|
||||
expect(agent.labels).toEqual({ phase: "implementation" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar entity snapshots", () => {
|
||||
it("interns cross-host references and preserves identity across unrelated reads", () => {
|
||||
const first = createPluginWorkspaceSnapshot(workspace, "host-1");
|
||||
const second = createPluginWorkspaceSnapshot(workspace, "host-2");
|
||||
expect(first.ref).not.toBe(second.ref);
|
||||
expect(first.ref.hostId).toBe("host-1");
|
||||
expect(first.project.id).toBe(workspace.projectId);
|
||||
expect(Object.isFrozen(first.labels)).toBe(true);
|
||||
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(first);
|
||||
expect(createPluginAgentSnapshot(agent, workspace.id).workspace).toBe(first.ref);
|
||||
});
|
||||
|
||||
it("invalidates a done workspace when its effective root-agent status changes", () => {
|
||||
const idle: WorkspaceDescriptor = { ...workspace, status: "done" };
|
||||
const before = createPluginWorkspaceSnapshot(idle, "host-1");
|
||||
const activity = new Map([
|
||||
[idle.id, { agentId: agent.id, status: "needs_input" as const, enteredAt: null }],
|
||||
]);
|
||||
const after = createPluginWorkspaceSnapshot(idle, "host-1", {
|
||||
workspaceAgentActivity: activity,
|
||||
});
|
||||
expect(before.indicator).toBe("done");
|
||||
expect(after.status).toBe("done");
|
||||
expect(after.indicator).toBe("needs_input");
|
||||
expect(after.indicatorLabel).toBe("Needs input");
|
||||
expect(after.ref).toBe(before.ref);
|
||||
expect(
|
||||
createPluginWorkspaceSnapshot(idle, "host-1", { workspaceAgentActivity: activity }),
|
||||
).toBe(after);
|
||||
});
|
||||
|
||||
it("reads projects without requiring a workspace", () => {
|
||||
const project = {
|
||||
projectId: "empty",
|
||||
projectDisplayName: "Empty",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/empty",
|
||||
projectKind: "git" as const,
|
||||
};
|
||||
const snapshot = createPluginProjectSnapshot(project, "host-1");
|
||||
expect(createPluginProjectSnapshot(project, "host-1")).toBe(snapshot);
|
||||
expect(snapshot.displayName).toBe("Empty");
|
||||
expect(snapshot.ref.id).toBe("empty");
|
||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the app status labels and invalidates a snapshot on language change", async () => {
|
||||
i18n.addResourceBundle("fr", "translation", fr);
|
||||
const previousLanguage = i18n.language;
|
||||
try {
|
||||
await i18n.changeLanguage("en");
|
||||
const english = createPluginWorkspaceSnapshot(workspace, "host-1");
|
||||
expect(english.indicatorLabel).toBe("Working");
|
||||
await i18n.changeLanguage("fr");
|
||||
const french = createPluginWorkspaceSnapshot(workspace, "host-1");
|
||||
expect(french).not.toBe(english);
|
||||
expect(french.ref).toBe(english.ref);
|
||||
expect(french.indicatorLabel).toBe("En cours");
|
||||
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(french);
|
||||
} finally {
|
||||
await i18n.changeLanguage(previousLanguage);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the host-qualified parent of a remotely delegated agent", () => {
|
||||
const child = {
|
||||
...agent,
|
||||
serverId: "host-2",
|
||||
parentAgentId: null,
|
||||
labels: { "paseo.parent-server-id": "host-1", "paseo.parent-agent-id": "parent" },
|
||||
};
|
||||
expect(createPluginAgentSnapshot(child, workspace.id).parentAgent).toMatchObject({
|
||||
hostId: "host-1",
|
||||
id: "parent",
|
||||
kind: "agent",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,57 @@
|
||||
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type {
|
||||
PluginAgentSnapshot,
|
||||
PluginWorkspaceSnapshot,
|
||||
PluginProjectSnapshot,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { Agent, WorkspaceDescriptor, ProjectDescriptor } from "@/stores/session-store";
|
||||
|
||||
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
|
||||
import { entityRefs } from "./refs";
|
||||
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
|
||||
|
||||
interface CachedWorkspace {
|
||||
language: string;
|
||||
snapshot: PluginWorkspaceSnapshot;
|
||||
}
|
||||
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, Map<string, CachedWorkspace>>();
|
||||
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
|
||||
|
||||
export function createPluginWorkspaceSnapshot(
|
||||
workspace: WorkspaceDescriptor,
|
||||
hostId: string,
|
||||
context: Omit<Parameters<typeof deriveEffectiveWorkspaceStatus>[0], "workspace" | "serverId"> & {
|
||||
parentAgent?: PluginWorkspaceSnapshot["parentAgent"];
|
||||
} = {},
|
||||
): PluginWorkspaceSnapshot {
|
||||
const cached = workspaceSnapshots.get(workspace);
|
||||
if (cached) return cached;
|
||||
const indicator = deriveEffectiveWorkspaceStatus({
|
||||
...context,
|
||||
workspace,
|
||||
serverId: hostId,
|
||||
}).status;
|
||||
const parentAgent = context.parentAgent ?? null;
|
||||
const cached = workspaceSnapshots.get(workspace)?.get(hostId);
|
||||
if (
|
||||
cached &&
|
||||
cached.language === i18n.language &&
|
||||
cached.snapshot.indicator === indicator &&
|
||||
cached.snapshot.parentAgent === parentAgent
|
||||
)
|
||||
return cached.snapshot;
|
||||
const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null;
|
||||
const snapshot = Object.freeze({
|
||||
ref: entityRefs.workspace(hostId, workspace.id),
|
||||
hostId,
|
||||
project: entityRefs.project(hostId, workspace.projectId),
|
||||
pinnedAt: workspace.pinnedAt ?? null,
|
||||
labels: Object.freeze([...(workspace.labels ?? [])]),
|
||||
background: workspace.background ?? false,
|
||||
branch: workspace.gitRuntime?.currentBranch ?? null,
|
||||
createdAt: serializeTimestamp(workspace.createdAt),
|
||||
lastPromptAt: serializeTimestamp(workspace.lastUserMessageAt),
|
||||
parentAgent,
|
||||
indicator,
|
||||
indicatorLabel: i18n.t(indicatorLabelKeys[indicator]),
|
||||
id: workspace.id,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
@@ -21,18 +62,25 @@ export function createPluginWorkspaceSnapshot(
|
||||
name: workspace.name,
|
||||
title: workspace.title ?? null,
|
||||
status: workspace.status,
|
||||
statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null,
|
||||
statusEnteredAt: serializeTimestamp(workspace.statusEnteredAt),
|
||||
archivingAt: workspace.archivingAt,
|
||||
diffStat,
|
||||
});
|
||||
workspaceSnapshots.set(workspace, snapshot);
|
||||
const byHost = workspaceSnapshots.get(workspace) ?? new Map<string, CachedWorkspace>();
|
||||
byHost.set(hostId, { language: i18n.language, snapshot });
|
||||
workspaceSnapshots.set(workspace, byHost);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot {
|
||||
const cached = agentSnapshots.get(agent)?.get(workspaceId);
|
||||
if (cached) return cached;
|
||||
const parent = getParentAddressFromLabels(agent.labels, agent.serverId);
|
||||
const snapshot = Object.freeze({
|
||||
ref: entityRefs.agent(agent.serverId, agent.id),
|
||||
hostId: agent.serverId,
|
||||
workspace: entityRefs.workspace(agent.serverId, workspaceId),
|
||||
parentAgent: parent ? entityRefs.agent(parent.serverId, parent.agentId) : null,
|
||||
id: agent.id,
|
||||
workspaceId,
|
||||
provider: agent.provider,
|
||||
@@ -55,3 +103,36 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
|
||||
agentSnapshots.set(agent, byWorkspace);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const indicatorLabelKeys = {
|
||||
needs_input: "sidebar.statusBucket.needsInput",
|
||||
failed: "sidebar.statusBucket.failed",
|
||||
running: "sidebar.statusBucket.working",
|
||||
attention: "sidebar.statusBucket.readyToReview",
|
||||
done: "sidebar.statusBucket.done",
|
||||
} as const;
|
||||
const projectSnapshots = new WeakMap<ProjectDescriptor, Map<string, PluginProjectSnapshot>>();
|
||||
|
||||
export function createPluginProjectSnapshot(
|
||||
project: ProjectDescriptor,
|
||||
hostId: string,
|
||||
): PluginProjectSnapshot {
|
||||
const cached = projectSnapshots.get(project)?.get(hostId);
|
||||
if (cached) return cached;
|
||||
const snapshot = Object.freeze({
|
||||
ref: entityRefs.project(hostId, project.projectId),
|
||||
hostId,
|
||||
displayName: project.projectCustomName ?? project.projectDisplayName,
|
||||
rootPath: project.projectRootPath,
|
||||
kind: project.projectKind,
|
||||
iconRevision: project.projectCustomIconRevision ?? project.projectIconRevision ?? null,
|
||||
});
|
||||
const byHost = projectSnapshots.get(project) ?? new Map<string, PluginProjectSnapshot>();
|
||||
byHost.set(hostId, snapshot);
|
||||
projectSnapshots.set(project, byHost);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function serializeTimestamp(value: Date | null): string | null {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { setFlagsFromString } from "node:v8";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { expect, it } from "vitest";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { createPluginClientStateSource } from "./source";
|
||||
import { workspace } from "./fixtures";
|
||||
|
||||
setFlagsFromString("--expose_gc");
|
||||
const collectGarbage: () => void = runInNewContext("gc");
|
||||
setFlagsFromString("--no-expose_gc");
|
||||
|
||||
function observeHost() {
|
||||
const hostId = "removed-plugin-host";
|
||||
let hosts = [hostId];
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(hostId, null);
|
||||
store.setWorkspaces(hostId, new Map([[workspace.id, { ...workspace }]]));
|
||||
const source = createPluginClientStateSource(undefined, {
|
||||
hosts: { getSnapshot: () => hosts, subscribe: () => () => {} },
|
||||
});
|
||||
const release = source.subscribe(() => source.getSnapshot());
|
||||
source.getSnapshot();
|
||||
const reference = new WeakRef(useSessionStore.getState().sessions[hostId]!.workspaces);
|
||||
const remove = () => {
|
||||
hosts = [];
|
||||
store.clearSession(hostId);
|
||||
};
|
||||
return { reference, release, remove };
|
||||
}
|
||||
|
||||
it("releases removed hosts' projected data while other plugin readers remain", async () => {
|
||||
const { reference, release, remove } = observeHost();
|
||||
remove();
|
||||
try {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await setImmediate();
|
||||
collectGarbage();
|
||||
}
|
||||
expect(reference.deref()).toBeUndefined();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases previous projections when the last plugin reader leaves", async () => {
|
||||
const { reference, release, remove } = observeHost();
|
||||
release();
|
||||
remove();
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await setImmediate();
|
||||
collectGarbage();
|
||||
}
|
||||
expect(reference.deref()).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
import { Nested } from "../../../../../plugin-examples/sidebar-views/client/nested";
|
||||
import { lightTheme } from "@/styles/theme";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { createConnectedPluginHosts } from "./connected-hosts";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { fr } from "@/i18n/resources/fr";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import {
|
||||
useAgents,
|
||||
useWorkspaces,
|
||||
useWorkspace,
|
||||
useAgent,
|
||||
type WorkspaceRef,
|
||||
type AgentRef,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { createPluginClientStateSource, type PluginConnectedHostsSource } from "./source";
|
||||
import { agent, workspace } from "./fixtures";
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
const renders = new Map<string, number>();
|
||||
function count(key: string) {
|
||||
renders.set(key, (renders.get(key) ?? 0) + 1);
|
||||
}
|
||||
function WorkspaceRow({ reference }: { reference: WorkspaceRef }) {
|
||||
const item = useWorkspace(reference);
|
||||
count(reference.key);
|
||||
return (
|
||||
<div>
|
||||
{item?.name}:{item?.indicator}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Workspaces() {
|
||||
count("workspaces");
|
||||
const items = useWorkspaces({ orderBy: "name" });
|
||||
return (
|
||||
<div>
|
||||
{items.map((reference) => (
|
||||
<WorkspaceRow key={reference.key} reference={reference} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function SelectedWorkspace({ reference }: { reference: WorkspaceRef }) {
|
||||
const name = useWorkspace(reference, (item) => ({ name: item.name }));
|
||||
const custom = useWorkspace(reference, undefined, (a, b) => a?.name === b?.name);
|
||||
count("selected");
|
||||
return (
|
||||
<div>
|
||||
{name?.name}:{custom?.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function AgentRow({ reference }: { reference: AgentRef }) {
|
||||
const item = useAgent(reference);
|
||||
return <div>{item?.title}</div>;
|
||||
}
|
||||
function AgentSection({ section }: { section: string }) {
|
||||
const agents = useAgents({
|
||||
where: (item) => (item.requiresAttention ? "waiting" : item.status) === section,
|
||||
});
|
||||
count(section);
|
||||
return (
|
||||
<div>
|
||||
{agents.map((reference) => (
|
||||
<AgentRow key={reference.key} reference={reference} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Agents() {
|
||||
return (
|
||||
<div>
|
||||
<AgentSection section="running" />
|
||||
<AgentSection section="waiting" />
|
||||
<AgentSection section="idle" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function seedHost(hostId: string) {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(hostId, null);
|
||||
store.setWorkspaces(
|
||||
hostId,
|
||||
new Map([
|
||||
["workspace-1", { ...workspace, id: "workspace-1", status: "done", name: "First" }],
|
||||
["workspace-2", { ...workspace, id: "workspace-2", status: "done", name: "Second" }],
|
||||
]),
|
||||
);
|
||||
store.setProjects(hostId, [
|
||||
{
|
||||
projectId: "empty",
|
||||
projectDisplayName: "Empty project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/empty",
|
||||
projectKind: "git",
|
||||
},
|
||||
]);
|
||||
store.setAgents(
|
||||
hostId,
|
||||
new Map<string, Agent>([
|
||||
[
|
||||
"agent-1",
|
||||
{
|
||||
...agent,
|
||||
serverId: hostId,
|
||||
id: "agent-1",
|
||||
workspaceId: "workspace-1",
|
||||
status: "running",
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
|
||||
},
|
||||
],
|
||||
[
|
||||
"agent-2",
|
||||
{
|
||||
...agent,
|
||||
serverId: hostId,
|
||||
id: "agent-2",
|
||||
workspaceId: "workspace-2",
|
||||
status: "running",
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
it("keeps archived agents readable individually while excluding them from collections", () => {
|
||||
seedHost("host-1");
|
||||
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
|
||||
const reference = source.getSnapshot().agents[0]!.ref;
|
||||
useSessionStore.getState().setAgents(
|
||||
"host-1",
|
||||
new Map([
|
||||
[
|
||||
agent.id,
|
||||
{
|
||||
...agent,
|
||||
archivedAt: new Date("2026-11-01T00:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(source.getSnapshot().agents).toEqual([]);
|
||||
expect(source.getAgent(reference)?.title).toBe(agent.title);
|
||||
expect(source.getAgent(agent.id)).toBe(source.getAgent(reference));
|
||||
});
|
||||
|
||||
it("demands directories for connected hosts and releases them with its last reader", () => {
|
||||
const hostId = "plugin-demand-host";
|
||||
let online = true;
|
||||
let demands = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
const hosts = createConnectedPluginHosts(() => ({
|
||||
getHosts: () => [{ serverId: hostId }],
|
||||
getSnapshot: () => ({ connectionStatus: online ? "online" : "offline" }),
|
||||
subscribeAll: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
subscribeHostList: () => () => {},
|
||||
acquireDirectoryDemand: () => {
|
||||
demands++;
|
||||
seedHost(hostId);
|
||||
return () => {
|
||||
demands--;
|
||||
};
|
||||
},
|
||||
}));
|
||||
const source = createPluginClientStateSource(undefined, { hosts });
|
||||
const first = source.subscribe(() => {});
|
||||
const second = source.subscribe(() => {});
|
||||
expect(demands).toBe(1);
|
||||
expect(source.getSnapshot().workspaces.map((item) => item.hostId)).toEqual([hostId, hostId]);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(
|
||||
hostId,
|
||||
(previous) =>
|
||||
new Map([...previous].map(([id, item]) => [id, { ...item, name: "Live update" }])),
|
||||
);
|
||||
expect(source.getSnapshot().workspaces.map((item) => item.name)).toEqual([
|
||||
"Live update",
|
||||
"Live update",
|
||||
]);
|
||||
online = false;
|
||||
for (const listener of listeners) listener();
|
||||
expect(demands).toBe(0);
|
||||
expect(source.getSnapshot().workspaces).toEqual([]);
|
||||
online = true;
|
||||
for (const listener of listeners) listener();
|
||||
expect(demands).toBe(1);
|
||||
first();
|
||||
expect(demands).toBe(1);
|
||||
second();
|
||||
expect(demands).toBe(0);
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
function waitForInput() {
|
||||
useSessionStore.getState().setAgents("host-1", (previous) => {
|
||||
const current = previous.get("agent-1");
|
||||
if (!current) throw new Error("Missing test agent");
|
||||
const next = new Map(previous);
|
||||
next.set(current.id, {
|
||||
...current,
|
||||
status: "idle",
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
class ConnectedHosts implements PluginConnectedHostsSource {
|
||||
private ids: readonly string[] = ["host-1"];
|
||||
private listeners = new Set<() => void>();
|
||||
getSnapshot = () => this.ids;
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
connect(id: string) {
|
||||
this.ids = [...this.ids, id];
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
function mount(children: React.ReactNode, hosts = new ConnectedHosts()) {
|
||||
const source = createPluginClientStateSource("host-1", { hosts });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() =>
|
||||
root?.render(<PluginClientStateProvider source={source}>{children}</PluginClientStateProvider>),
|
||||
);
|
||||
return source;
|
||||
}
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
useSessionStore.getState().clearSession("host-1");
|
||||
useSessionStore.getState().clearSession("host-2");
|
||||
renders.clear();
|
||||
});
|
||||
|
||||
it("shows a restored child in the nested example while its cached parent remains archived", () => {
|
||||
seedHost("host-1");
|
||||
const parent = {
|
||||
...agent,
|
||||
id: "archived-parent",
|
||||
title: "Archived parent",
|
||||
archivedAt: new Date("2026-11-01T00:00:00Z"),
|
||||
};
|
||||
const child = {
|
||||
...agent,
|
||||
id: "restored-child",
|
||||
title: "Restored child",
|
||||
parentAgentId: parent.id,
|
||||
labels: { "paseo.parent-agent-id": parent.id },
|
||||
archivedAt: parent.archivedAt,
|
||||
};
|
||||
const store = useSessionStore.getState();
|
||||
store.setProjects("host-1", [
|
||||
{
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: "Project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: "git",
|
||||
},
|
||||
]);
|
||||
store.setAgents(
|
||||
"host-1",
|
||||
new Map<string, Agent>([
|
||||
[parent.id, parent],
|
||||
[child.id, child],
|
||||
]),
|
||||
);
|
||||
const view = {
|
||||
theme: toPluginTheme(lightTheme),
|
||||
layout: { compact: false, platform: "web" as const },
|
||||
active: { workspace: null, agent: null },
|
||||
navigation: { openWorkspace() {}, openAgent() {}, openProject() {}, openTerminal() {} },
|
||||
dismiss() {},
|
||||
};
|
||||
const source = mount(<Nested {...view} />);
|
||||
expect(source.getAgent(parent.id)?.title).toBe(parent.title);
|
||||
expect(container?.textContent).not.toContain(child.title);
|
||||
act(() =>
|
||||
store.setAgents(
|
||||
"host-1",
|
||||
new Map<string, Agent>([
|
||||
[parent.id, parent],
|
||||
[child.id, { ...child, archivedAt: null }],
|
||||
]),
|
||||
),
|
||||
);
|
||||
expect(container?.querySelector('[aria-label="Open agent Restored child"]')).not.toBeNull();
|
||||
expect(container?.querySelector('[aria-label="Open agent Archived parent"]')).toBeNull();
|
||||
});
|
||||
|
||||
describe("sidebar data hooks through the app store and plugin provider", () => {
|
||||
it("renders only one workspace row when its root agent starts waiting", () => {
|
||||
seedHost("host-1");
|
||||
const source = mount(<Workspaces />);
|
||||
const first = source.getSnapshot().workspaces[0]!;
|
||||
const second = source.getSnapshot().workspaces[1]!;
|
||||
const before = source.getSnapshot();
|
||||
act(waitForInput);
|
||||
expect(renders.get("workspaces")).toBe(1);
|
||||
expect(renders.get(first.ref.key)).toBe(2);
|
||||
expect(renders.get(second.ref.key)).toBe(1);
|
||||
expect(source.getSnapshot().workspaces[0]?.indicator).toBe("needs_input");
|
||||
expect(source.getSnapshot().workspaces[1]).toBe(before.workspaces[1]);
|
||||
});
|
||||
it("moves the agent and renders only its two affected sections without memo", () => {
|
||||
seedHost("host-1");
|
||||
mount(<Agents />);
|
||||
act(waitForInput);
|
||||
expect(renders.get("running")).toBe(2);
|
||||
expect(renders.get("waiting")).toBe(2);
|
||||
expect(renders.get("idle")).toBe(1);
|
||||
});
|
||||
it("uses shallow selected equality and custom equality without a selector", () => {
|
||||
seedHost("host-1");
|
||||
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
|
||||
mount(<SelectedWorkspace reference={source.getSnapshot().workspaces[0]!.ref} />);
|
||||
act(waitForInput);
|
||||
expect(renders.get("selected")).toBe(1);
|
||||
});
|
||||
it("refreshes translated labels through the subscribed source when language changes", async () => {
|
||||
seedHost("host-1");
|
||||
i18n.addResourceBundle("fr", "translation", fr);
|
||||
const source = mount(<Workspaces />);
|
||||
const before = source.getSnapshot().workspaces[0]!;
|
||||
const previousLanguage = i18n.language;
|
||||
try {
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
});
|
||||
const after = source.getSnapshot().workspaces[0]!;
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.indicatorLabel).toBe("En cours");
|
||||
expect(after.ref).toBe(before.ref);
|
||||
expect(renders.get(before.ref.key)).toBe(2);
|
||||
expect(renders.get("workspaces")).toBe(1);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage(previousLanguage);
|
||||
});
|
||||
}
|
||||
});
|
||||
it("adds a newly connected host and exposes projects with no workspaces", () => {
|
||||
seedHost("host-1");
|
||||
seedHost("host-2");
|
||||
const hosts = new ConnectedHosts();
|
||||
const source = mount(<Workspaces />, hosts);
|
||||
expect(source.getSnapshot().workspaces).toHaveLength(2);
|
||||
expect(source.getSnapshot().projects[0]?.displayName).toBe("Empty project");
|
||||
act(() => hosts.connect("host-2"));
|
||||
expect(source.getSnapshot().workspaces).toHaveLength(4);
|
||||
expect(source.getSnapshot().projects).toHaveLength(2);
|
||||
expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("observes terminals only while a terminal reader is subscribed", () => {
|
||||
let active = 0;
|
||||
const source = createPluginClientStateSource(undefined, {
|
||||
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
|
||||
terminals: {
|
||||
getSnapshot: () => [],
|
||||
subscribe: () => {
|
||||
active++;
|
||||
return () => {
|
||||
active--;
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const releaseWorkspace = source.subscribe(() => {});
|
||||
expect(active).toBe(0);
|
||||
const releaseTerminal = source.subscribe(() => {}, { terminals: true });
|
||||
const releaseSecond = source.subscribe(() => {}, { terminals: true });
|
||||
expect(active).toBe(1);
|
||||
releaseTerminal();
|
||||
expect(active).toBe(1);
|
||||
releaseSecond();
|
||||
expect(active).toBe(0);
|
||||
releaseWorkspace();
|
||||
});
|
||||
|
||||
it("terminal row reads propagate only the referenced host's observation error", () => {
|
||||
const error = new Error("Update the host");
|
||||
const source = createPluginClientStateSource(undefined, {
|
||||
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
|
||||
terminals: {
|
||||
getSnapshot: () => [],
|
||||
subscribe: () => () => {},
|
||||
getErrors: () => new Map([["unsupported", error]]),
|
||||
},
|
||||
});
|
||||
const refs = createEntityRefs();
|
||||
expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error);
|
||||
expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull();
|
||||
});
|
||||
@@ -1,21 +1,248 @@
|
||||
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { selectWorkspace } from "@/stores/session-store-hooks/selectors";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { PluginClientSnapshot, PluginClientStateSource } from "@getpaseo/plugin/client/host";
|
||||
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
|
||||
import { connectedPluginHosts } from "./connected-hosts";
|
||||
import { pluginTerminals } from "./terminals";
|
||||
import { entityRefs } from "./refs";
|
||||
import {
|
||||
createPluginAgentSnapshot,
|
||||
createPluginProjectSnapshot,
|
||||
createPluginWorkspaceSnapshot,
|
||||
} from "./snapshots";
|
||||
|
||||
export function createPluginClientStateSource(serverId: string): PluginClientStateSource {
|
||||
return {
|
||||
subscribe: useSessionStore.subscribe,
|
||||
getWorkspace(workspaceId) {
|
||||
const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId);
|
||||
return workspace ? createPluginWorkspaceSnapshot(workspace) : null;
|
||||
export interface PluginConnectedHostsSource {
|
||||
subscribe(listener: () => void): () => void;
|
||||
getSnapshot(): readonly string[];
|
||||
}
|
||||
|
||||
export interface PluginTerminalsSource {
|
||||
subscribe(listener: () => void): () => void;
|
||||
getSnapshot(): readonly PluginTerminalSnapshot[];
|
||||
getErrors?(): ReadonlyMap<string, Error>;
|
||||
}
|
||||
|
||||
const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]);
|
||||
const listeners = new Set<() => void>();
|
||||
let releaseStores: (() => void) | null = null;
|
||||
|
||||
// Every plugin host shares one subscription to each app store.
|
||||
function subscribeStores(listener: () => void): () => void {
|
||||
if (!releaseStores) {
|
||||
const notify = () => {
|
||||
for (const subscriber of listeners) subscriber();
|
||||
};
|
||||
i18n.on("languageChanged", notify);
|
||||
const releaseSession = useSessionStore.subscribe((state) => {
|
||||
for (const hostId of hostProjections.keys()) {
|
||||
if (!state.sessions[hostId]) hostProjections.delete(hostId);
|
||||
}
|
||||
notify();
|
||||
});
|
||||
const releaseCreate = useCreateFlowStore.subscribe(notify);
|
||||
releaseStores = () => {
|
||||
i18n.off("languageChanged", notify);
|
||||
releaseSession();
|
||||
releaseCreate();
|
||||
};
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0 && releaseStores) {
|
||||
releaseStores();
|
||||
releaseStores = null;
|
||||
hostProjections.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface HostProjection {
|
||||
language: string;
|
||||
workspaces: SessionState["workspaces"];
|
||||
agents: SessionState["agents"];
|
||||
projects: SessionState["projects"];
|
||||
activity: SessionState["workspaceAgentActivity"];
|
||||
pending: ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
|
||||
snapshot: Omit<PluginClientSnapshot, "terminals">;
|
||||
workspaceById: Map<string, PluginClientSnapshot["workspaces"][number]>;
|
||||
agentById: Map<string, PluginClientSnapshot["agents"][number]>;
|
||||
projectById: Map<string, PluginClientSnapshot["projects"][number]>;
|
||||
}
|
||||
const hostProjections = new Map<string, HostProjection>();
|
||||
|
||||
function projectHost(hostId: string): HostProjection | null {
|
||||
const session = useSessionStore.getState().sessions[hostId];
|
||||
if (!session) {
|
||||
hostProjections.delete(hostId);
|
||||
return null;
|
||||
}
|
||||
const pending = useCreateFlowStore.getState().pendingByDraftId;
|
||||
const previous = hostProjections.get(hostId);
|
||||
const unchanged =
|
||||
previous &&
|
||||
previous.language === i18n.language &&
|
||||
previous.workspaces === session.workspaces &&
|
||||
previous.agents === session.agents &&
|
||||
previous.projects === session.projects &&
|
||||
previous.activity === session.workspaceAgentActivity &&
|
||||
previous.pending === pending;
|
||||
if (unchanged) return previous;
|
||||
const workspaces = Array.from(session.workspaces.values(), (workspace) => {
|
||||
const activity = session.workspaceAgentActivity.get(workspace.id);
|
||||
const root = activity ? session.agents.get(activity.agentId) : undefined;
|
||||
return createPluginWorkspaceSnapshot(workspace, hostId, {
|
||||
pendingCreateAttempts: pending,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
parentAgent: root ? createPluginAgentSnapshot(root, workspace.id).parentAgent : null,
|
||||
});
|
||||
});
|
||||
const agents: PluginClientSnapshot["agents"][number][] = [];
|
||||
for (const agent of session.agents.values()) {
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId);
|
||||
if (!workspaceId || agent.archivedAt) continue;
|
||||
agents.push(createPluginAgentSnapshot(agent, workspaceId));
|
||||
}
|
||||
const projects = Array.from(session.projects.values(), (project) =>
|
||||
createPluginProjectSnapshot(project, hostId),
|
||||
);
|
||||
const projection: HostProjection = {
|
||||
language: i18n.language,
|
||||
workspaces: session.workspaces,
|
||||
agents: session.agents,
|
||||
projects: session.projects,
|
||||
activity: session.workspaceAgentActivity,
|
||||
pending,
|
||||
snapshot: {
|
||||
workspaces: Object.freeze(workspaces),
|
||||
agents: Object.freeze(agents),
|
||||
projects: Object.freeze(projects),
|
||||
},
|
||||
getAgent(agentId) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agent?.workspaceId);
|
||||
return agent && workspaceId ? createPluginAgentSnapshot(agent, workspaceId) : null;
|
||||
workspaceById: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
|
||||
agentById: new Map(agents.map((agent) => [agent.id, agent])),
|
||||
projectById: new Map(projects.map((project) => [project.ref.id, project])),
|
||||
};
|
||||
hostProjections.set(hostId, projection);
|
||||
return projection;
|
||||
}
|
||||
|
||||
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
/** A host scope only resolves legacy string ids. Ref lookups and collections span hosts. */
|
||||
export function createPluginClientStateSource(
|
||||
serverId?: string,
|
||||
options: { hosts?: PluginConnectedHostsSource; terminals?: PluginTerminalsSource } = {},
|
||||
): PluginClientStateSource {
|
||||
const terminals = options.terminals ?? pluginTerminals;
|
||||
const hosts = options.hosts ?? connectedPluginHosts;
|
||||
const subscribers = new Set<() => void>();
|
||||
const terminalSubscribers = new Set<() => void>();
|
||||
let releaseTerminals: (() => void) | null = null;
|
||||
let release: (() => void) | null = null;
|
||||
let previousProjections: readonly HostProjection[] = [];
|
||||
let previousTerminals = emptyTerminals;
|
||||
let previousTerminalErrors: ReadonlyMap<string, Error> | undefined;
|
||||
let snapshot: PluginClientSnapshot = {
|
||||
workspaces: [],
|
||||
agents: [],
|
||||
projects: [],
|
||||
terminals: emptyTerminals,
|
||||
};
|
||||
function getSnapshot(): PluginClientSnapshot {
|
||||
const ids = hosts.getSnapshot();
|
||||
const projections: HostProjection[] = [];
|
||||
for (const id of ids) {
|
||||
const projection = projectHost(id);
|
||||
if (projection) projections.push(projection);
|
||||
}
|
||||
const terminalSnapshots = terminals.getSnapshot();
|
||||
const terminalErrors = terminals.getErrors?.();
|
||||
if (
|
||||
sameSequence(previousProjections, projections) &&
|
||||
previousTerminals === terminalSnapshots &&
|
||||
previousTerminalErrors === terminalErrors
|
||||
)
|
||||
return snapshot;
|
||||
previousProjections = projections;
|
||||
previousTerminals = terminalSnapshots;
|
||||
previousTerminalErrors = terminalErrors;
|
||||
snapshot = Object.freeze({
|
||||
workspaces: Object.freeze(projections.flatMap((host) => host.snapshot.workspaces)),
|
||||
agents: Object.freeze(projections.flatMap((host) => host.snapshot.agents)),
|
||||
projects: Object.freeze(projections.flatMap((host) => host.snapshot.projects)),
|
||||
terminals: terminalSnapshots,
|
||||
terminalErrors,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
return {
|
||||
subscribe(listener, subscriptionOptions) {
|
||||
subscribers.add(listener);
|
||||
if (subscriptionOptions?.terminals) {
|
||||
terminalSubscribers.add(listener);
|
||||
releaseTerminals ??= terminals.subscribe(() => {
|
||||
for (const subscriber of terminalSubscribers) subscriber();
|
||||
});
|
||||
}
|
||||
if (!release) {
|
||||
const notify = () => {
|
||||
for (const subscriber of subscribers) subscriber();
|
||||
};
|
||||
const releaseStore = subscribeStores(notify);
|
||||
const releaseHosts = hosts.subscribe(notify);
|
||||
release = () => {
|
||||
releaseStore();
|
||||
releaseHosts();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
subscribers.delete(listener);
|
||||
terminalSubscribers.delete(listener);
|
||||
if (terminalSubscribers.size === 0 && releaseTerminals) {
|
||||
releaseTerminals();
|
||||
releaseTerminals = null;
|
||||
}
|
||||
if (subscribers.size === 0 && release) {
|
||||
release();
|
||||
release = null;
|
||||
previousProjections = [];
|
||||
previousTerminals = emptyTerminals;
|
||||
previousTerminalErrors = undefined;
|
||||
snapshot = { workspaces: [], agents: [], projects: [], terminals: emptyTerminals };
|
||||
}
|
||||
};
|
||||
},
|
||||
getSnapshot,
|
||||
getWorkspace(reference) {
|
||||
const ref =
|
||||
typeof reference === "string" && serverId
|
||||
? entityRefs.workspace(serverId, reference)
|
||||
: reference;
|
||||
if (typeof ref === "string") return null;
|
||||
return projectHost(ref.hostId)?.workspaceById.get(ref.id) ?? null;
|
||||
},
|
||||
getAgent(reference) {
|
||||
const ref =
|
||||
typeof reference === "string" && serverId
|
||||
? entityRefs.agent(serverId, reference)
|
||||
: reference;
|
||||
if (typeof ref === "string") return null;
|
||||
const projected = projectHost(ref.hostId)?.agentById.get(ref.id);
|
||||
if (projected) return projected;
|
||||
const session = useSessionStore.getState().sessions[ref.hostId];
|
||||
const detail = session?.agents.get(ref.id) ?? session?.agentDetails.get(ref.id);
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(detail?.workspaceId);
|
||||
return detail && workspaceId ? createPluginAgentSnapshot(detail, workspaceId) : null;
|
||||
},
|
||||
getProject: (ref) => projectHost(ref.hostId)?.projectById.get(ref.id) ?? null,
|
||||
getTerminal(ref) {
|
||||
const error = terminals.getErrors?.().get(ref.hostId);
|
||||
if (error) throw error;
|
||||
return terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user