Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 0d1cea9ac0 test(server): reproduce missed cross-client agent deletion
Retain the failing reproduction for #4870. Deletion reaches the requesting subscription but leaves other connected agent subscriptions stale. No production behavior is changed.
2026-10-18 19:03:06 +02:00
303cebec06 Keep sidebar setup actions inside the empty-state card (#7312)
* fix(app): wrap sidebar empty-state actions so longer labels stay inside the card

The "Add project" / "Import session" row in the sidebar's empty state is a
non-wrapping flex row. With longer translations (e.g. Russian) the second
button overflows the card at the default 320px sidebar width.

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

* test(app): cover sidebar empty-state actions staying inside the card

Renders the empty state in a real browser at the default sidebar width and
checks both actions' bounds against the card, for Russian (fails without the
wrap) and English.

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

* fix(app): keep long sidebar actions inside narrow cards

Wrapping the row still lets a single translated action exceed its available width. Bound each empty-state action to the row and allow its label to shrink and wrap.

Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>

---------

Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 17:54:51 +02:00
paseo-bot[bot] fcda2f1cf0 fix(hub): reconnect after silent connection loss (#7315)
A network path can swallow close notifications while the Hub socket remains open locally. Probe it with protocol ping/pong frames and feed stale sockets into the existing reconnect path.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 17:00:13 +02:00
Mohamed Boudra f7a0e2cbea test(server): wait up to ten seconds for the OMP relaunch to register MCP tools (#7304)
The crash-relaunch test waited with vi.waitFor's default one second for a
fresh runtime and MCP child to come up. On the Windows runner with three
workers that took longer once in six runs and failed main. The assertions
are unchanged; only the budget for the relaunch is realistic now.
2026-10-18 21:11:14 +07:00
paseo-ai[bot] 2a9e86b542 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-18 11:09:08 +00:00
paseo-bot[bot]andMichael Whitman 9ed4f1069b fix(app): create project workspaces on the active host (#7308)
Grouped sidebar projects chose the first eligible host even while a workspace on another host was active. Prefer that active host when online and eligible, keeping the existing fallback for other placements.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Whitman <michael.whitman@getsteward.ai>
2026-10-18 12:57:54 +02:00
Mohamed Boudra cc748dfe12 chore(release): cut 0.12.0-beta.3 2026-10-18 12:47:46 +02:00
Mohamed Boudra 38a8e60780 docs(changelog): 0.12.0-beta.3 2026-10-18 12:43:26 +02:00
paseo-bot[bot]andIvan Malison 60fa12c4de Report disconnected hosts in new agent drafts (#7303)
* fix(app): report offline hosts in new agent drafts

A disconnected client still exists, and pending form resolution and retained loading catalogs outlive the connection. Use host connection state for draft readiness and model loading while keeping known models available for reconnect.

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

* test(app): confirm draft recovery on a fresh host connection

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-10-18 11:57:41 +02:00
Mohamed Boudra 233997ed5c Give Markdown blocks one consistent gap, roomier on desktop, and a zoom-in cursor on images (#7298)
* fix(scripts): recover installs when a dependency patch changed

patch-package cannot apply a patch over a package patched with an earlier
version of it, and without --error-on-fail it exits 0 locally. Checkouts kept
the stale patch silently: the Markdown code block spacing fix never reached
them. The script now fails the install and removes that package so the next
npm install reinstalls it clean with the current patch. A patch-package that
cannot start fails the install without removing anything.

* fix(app): give Markdown blocks one gap and roomier desktop spacing

Every Markdown block is followed by one gap, density.blockGap: 12 on compact
layouts and 16 wider. The library's element styles and the views stacked
outside it (Mermaid segments on native, streaming head and tail) take the same
token, so no block type carries its own margins. Desktop was too tight at 12.

Markdown images show a zoom-in cursor on web, since tapping opens the lightbox.
2026-10-18 15:12:28 +07:00
Mohamed Boudra 3882b957a6 ci: rebalance Playwright shard weights from measured step minutes (#7297)
Weights derived from the Playwright step minutes of five eight-shard runs; shard 8 drops from about 16 minutes to 10 and the shards sit within about 4 minutes of each other once normalized for runner CPU.
2026-10-18 14:56:39 +07:00
paseo-bot[bot]andIvan Malison 095dd0316e fix(app): avoid pane rerenders while resizing Explorer (#7284)
Keep drag widths out of the shared stylesheet and skip unchanged panes during width previews. Preserve the existing resize state and persistence behavior.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <1246619+colonelpanic8@users.noreply.github.com>
2026-10-18 09:25:57 +02:00
Mohamed Boudra a65478d0bf Rank Command Center results by match then recency, with highlights, timestamps, and project icons (#7277)
* feat(app): rank, highlight, and timestamp Command Center results

Workspace and agent rows now show when they were last active, rank by
match quality and then recency, mark the query terms in every result
kind, and lead workspaces with their project icon.

Ties used to be broken by where the match landed in the string, so
"Deliver sidebar usage" beat "expose usage data" on sentence position
and recency almost never decided anything. Built-in rows now compare
only which fields matched and the matcher tier, then the most recent
row wins. The empty query follows the same rule, so it lists the most
recent workspaces and agents instead of alphabetical workspaces and
status-first agents.

Row and section heights live in list-metrics: a layout picks one row
inset and every height derives from it plus shared line boxes, which
the styles also read. The layout is chosen once at the top, so rows
never branch on it, and two-line rows are now vertically centered.
Desktop is denser (32/46); compact keeps 44pt touch rows (44/58).

findHighlightRanges takes the matcher's options so the marks match
what the list matched: the Command Center is not typo-tolerant, the
history list is.

* fix(app): keep the project icon out of Command Center row names

The fallback project icon draws the project's initial as text, which
became the first word of the row's accessible name ("C Command Center
..."), so screen readers announced it. The icon is decorative.

The scroll spec now reads the newest seeded workspace: the empty query
lists the most recent workspaces, so the first one seeded is no longer
among them.
2026-10-18 14:24:14 +07:00
Mohamed Boudra fc12843cef feat(app): show each subagent's model and effort under its title (#7290)
Paseo subagent rows in the subagents list had an empty info strip: the row
only rendered the daemon-provided subtitle, which only provider subagents
carry. The row now uses the same facts line an agent's sheet shows when it
opens without its composer, so both surfaces resolve model, thinking, mode,
and context the way the composer's controls do.

The facts sit under the title instead of beside it, so a long task name
keeps the row's width instead of fighting the strip for it.
2026-10-18 14:16:28 +07:00
131 changed files with 1883 additions and 4627 deletions
+6 -1
View File
@@ -145,6 +145,9 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Verify dependency patch recovery
run: node --test scripts/postinstall-patches.test.mjs
- name: Build server stack
run: npm run build:server
@@ -452,7 +455,9 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
E2E_WORKERS: "2"
# File groups are weighted by observed reporter durations.
PWTEST_SHARD_WEIGHTS: "93:100:101:104:91:94:122:110"
# Share of the test list per shard, rebalanced from the Playwright step
# minutes of five eight-shard runs so the slowest and fastest shards meet.
PWTEST_SHARD_WEIGHTS: "85:94:117:128:91:103:127:85"
- name: Upload test artifacts
uses: actions/upload-artifact@v4
+14 -3
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.12.0-beta.2 - 2026-10-17
## 0.12.0-beta.3 - 2026-10-18
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
@@ -27,6 +27,9 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
@@ -41,6 +44,8 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
### Fixed
@@ -54,7 +59,11 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
- Fixed new agent drafts staying on "Loading…" when the host is disconnected; they now say "Host is not connected" and keep your draft ([#7303](https://github.com/getpaseo/paseo/pull/7303) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed reloading a chat during a streaming reply showing a frozen copy above the live one and dropping the ending ([#7256](https://github.com/getpaseo/paseo/pull/7256))
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
@@ -68,7 +77,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
- Fixed background Claude subagent cards staying empty while the subagent works ([#6242](https://github.com/getpaseo/paseo/pull/6242))
- Fixed background Claude subagent cards staying empty while the subagent works, including after the parent finishes ([#6242](https://github.com/getpaseo/paseo/pull/6242), [#7266](https://github.com/getpaseo/paseo/pull/7266))
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
@@ -91,6 +100,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
@@ -102,7 +112,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message or closing Find ([#7033](https://github.com/getpaseo/paseo/pull/7033), [#7263](https://github.com/getpaseo/paseo/pull/7263) by [@panta82](https://github.com/panta82))
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
@@ -147,6 +157,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339))
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
## 0.11.4 - 2026-10-15
+1 -3
View File
@@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -126,8 +126,6 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
The whitespace is the design.
---
+3 -1
View File
@@ -535,7 +535,9 @@ For tighter loops, you can rebuild a single workspace:
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
runs when its package is actually present, so add the package to that script's `patchedPackages`
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
patch-package cannot apply a patch that changed after an earlier version was applied, so the
script removes that package and fails the install; the next `npm install` reinstalls it clean
with the current patch. Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
patch every build the consumers use: Metro resolves the `react-native` field of a package
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
+4
View File
@@ -4,6 +4,10 @@ Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat owns paced reveal, the web render cap, message identity and list measurement. Native renders completed messages together. While streaming, it keeps closed blocks in one head view and updates a separate tail, then reunites the text on completion. See [native parsing cost](agent-stream-performance.md#native-parsing-cost) for the boundary and pacing constraints.
## Block spacing
Every block is separated by one gap, `density.blockGap`: 12 on compact layouts, 16 wider. Inside the library's text, every element style ends with that gap and the library drops it after the last block. Views stacked outside the library (Mermaid segments, streaming head and tail) use the same gap through `MarkdownBlockStack`. Do not give a block type its own margins; change the gap.
## Links and images
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
+1 -1
View File
@@ -1 +1 @@
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/server": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,10 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37979,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38904,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38921,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38944,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/highlight": "0.12.0-beta.2",
"@getpaseo/plugin": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40119,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41727,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -26,7 +26,7 @@ test("agent list age advances and command center says just now for a fresh agent
const agentResult = commandCenter.getByTestId(
`command-center-agent-${getServerId()}:${session.agentId}`,
);
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
"just now",
);
await page.keyboard.press("Escape");
@@ -45,7 +45,8 @@ test("opening the command center keeps its first result fully visible", async ({
await expectCommandCenterToRemainAtTheTop(page);
await expectPrimaryCommandCenterActions(panel);
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
// The empty query lists the most recent workspaces, so the last one seeded leads it.
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
} finally {
await seeded.cleanup();
@@ -71,6 +71,14 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await closeSheet(page, "linked.ts");
});
await test.step("a subagent's row shows the facts its sheet shows", async () => {
await page.getByTestId("subagents-track-header").click();
await expect(page.getByTestId(`subagents-track-row-facts-${child.id}`)).toHaveText(
"Ten second stream · Low · Load test",
);
await tapSheetBackdrop(page);
});
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
@@ -0,0 +1,118 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
interface DragMeasurement {
observer: MutationObserver;
stylesheetMutations: number;
}
declare global {
interface Window {
__PASEO_EXPLORER_DRAG_MEASUREMENT__?: DragMeasurement;
}
}
async function waitForResizePaint(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
async function beginResizeMeasurement(page: Page): Promise<void> {
await page.evaluate(() => {
const stylesheet = document.getElementById("unistyles-web");
if (!stylesheet) throw new Error("Shared stylesheet is missing");
globalThis.__PASEO_RESET_RENDER_PROFILE__!();
const measurement: DragMeasurement = {
stylesheetMutations: 0,
observer: new MutationObserver((records) => {
measurement.stylesheetMutations += records.length;
}),
};
measurement.observer.observe(stylesheet, {
childList: true,
subtree: true,
characterData: true,
});
window.__PASEO_EXPLORER_DRAG_MEASUREMENT__ = measurement;
});
}
async function previewExplorerResize(page: Page, distance: number) {
const handle = page
.getByTestId("workspace-explorer-sidebar-resize-handle")
.getByRole("separator");
const explorer = page.getByTestId("workspace-explorer-sidebar");
const bounds = await handle.boundingBox();
if (!bounds) throw new Error("Explorer resize handle is missing");
const before = (await explorer.boundingBox())!.width;
const x = bounds.x + bounds.width / 2;
const y = bounds.y + 100;
await page.mouse.move(x, y);
await page.mouse.down();
await waitForResizePaint(page);
await beginResizeMeasurement(page);
for (let move = 1; move <= 20; move++) {
await page.mouse.move(x + (distance * move) / 20, y);
await waitForResizePaint(page);
}
const during = (await explorer.boundingBox())!.width;
const measurement = await page.evaluate(() => {
const state = window.__PASEO_EXPLORER_DRAG_MEASUREMENT__!;
state.observer.disconnect();
return {
stylesheetMutations: state.stylesheetMutations,
paneRenders: Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {})
.filter(([id]) => id.startsWith("SplitPaneView:"))
.reduce((sum, [, reasons]) => sum + (reasons.render ?? 0), 0),
};
});
await page.mouse.up();
await waitForResizePaint(page);
const after = (await explorer.boundingBox())!.width;
expect(during).toBeCloseTo(before - distance, 0);
expect(after).toBeCloseTo(during, 0);
return { ...measurement, width: after };
}
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
await page.reload();
const explorer = page.getByTestId("workspace-explorer-sidebar");
await expect(explorer).toBeVisible();
await expect.poll(async () => (await explorer.boundingBox())?.width).toBeCloseTo(width, 0);
}
test("Explorer drag previews without rerendering panes or rewriting the shared stylesheet", async ({
page,
withWorkspace,
}) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await page.addInitScript(() => {
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
});
const workspace = await withWorkspace();
await workspace.navigateTo();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page.getByTestId("workspace-explorer-toggle").first().click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
// Verify that direct pane renders are being counted before resetting them.
expect(
await page.evaluate(() =>
Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).some((id) =>
id.startsWith("SplitPaneView:"),
),
),
).toBe(true);
const grown = await previewExplorerResize(page, -100);
const shrunk = await previewExplorerResize(page, 50);
expect(grown.stylesheetMutations).toBe(0);
expect(grown.paneRenders).toBe(0);
expect(shrunk.stylesheetMutations).toBe(0);
expect(shrunk.paneRenders).toBe(0);
await expectExplorerWidthSurvivesReload(page, shrunk.width);
});
@@ -0,0 +1,32 @@
import { expect, test } from "../support/fixtures";
import { expectComposerModel } from "../support/helpers/agent-profiles";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
import { submitMessage } from "../support/helpers/composer";
async function restoreHostConnection(gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>) {
gate.holdNextServerMessage("status");
gate.restore();
await gate.waitForHeldServerMessage("status");
gate.releaseHeldServerMessage("status");
}
test("an offline workspace draft reports the disconnected host instead of loading models", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace();
await workspace.navigateTo();
await gate.drop();
await gate.waitForBlockedConnection();
await createAgentTabFromMenu(page);
await submitMessage(page, "hello");
await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveText(
"Host is not connected",
);
await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0);
await restoreHostConnection(gate);
await expectComposerModel(page, "Ten second stream");
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello");
});
@@ -287,43 +287,22 @@ test("markdown links underline on hover", async ({ page }) => {
}
});
// The web code block replaces the library's own block, which must keep its spacing.
test("blocks around a web code block keep their spacing", async ({ page }) => {
const PIXEL_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
test("Markdown images show a zoom-in cursor", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "markdown-block-spacing-",
title: "Markdown block spacing",
featureValues: {
mockAssistantResponse:
"Intro.\n\n```sh\nnpx playwright test\n```\n\n| Step | Result |\n| --- | --- |\n| hover | none |\n\nMiddle.\n\n```ts\nconst a = 1;\n```\n\nClosing.",
},
initialPrompt: "Show blocks",
repoPrefix: "markdown-image-cursor-",
title: "Markdown image cursor",
featureValues: { mockAssistantResponse: `Before.\n\n![pixel](${PIXEL_PNG})\n\nAfter.` },
initialPrompt: "Show image",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("table")).toBeVisible();
const gaps = await measureBlockGaps(message.locator(".enrm-text").first());
for (const { between, gap } of gaps) expect(gap, between).toBeGreaterThanOrEqual(8);
const image = page.getByTestId("assistant-message").getByRole("button", { name: "pixel" });
await expect(image).toHaveCSS("cursor", "zoom-in");
} finally {
await session.cleanup();
}
});
async function measureBlockGaps(root: Locator) {
return root.evaluate((element) => {
const blocks = Array.from(element.children).map((block) => {
// Wrappers with display: contents have no box; measure their first rendered child.
let box: Element = block;
while (box.getBoundingClientRect().height === 0 && box.firstElementChild) {
box = box.firstElementChild;
}
const rect = box.getBoundingClientRect();
return { text: (block.textContent ?? "").slice(0, 20), top: rect.top, bottom: rect.bottom };
});
return blocks.slice(1).map((block, index) => ({
between: `${blocks[index].text} -> ${block.text}`,
gap: Math.round(block.top - blocks[index].bottom),
}));
});
}
@@ -9,6 +9,8 @@ import {
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openGroupedProjectWorkspace,
expectWorkspaceHost,
openProjectsForSettingsHost,
openProjectDirectory,
openProjectDirectoryWithHosts,
@@ -418,4 +420,29 @@ test.describe("Sidebar project grouping", () => {
count: 2,
});
});
test("creates grouped project workspaces on the active workspace host", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, SECONDARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: SECONDARY_HOST_LABEL,
count: 2,
});
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, PRIMARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: PRIMARY_HOST_LABEL,
count: 2,
});
});
});
@@ -203,7 +203,8 @@ export async function expectOneLineTruncatedFileResult(
expect(lineBox).not.toBeNull();
expect(filenameBox).not.toBeNull();
expect(pathBox).not.toBeNull();
expect(rowBox?.height).toBe(36);
// COMMAND_CENTER_LIST_METRICS.desktop.singleLineRow (list-metrics imports react-native).
expect(rowBox?.height).toBe(32);
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
expect(overflow.overflow).toBe("hidden");
@@ -134,3 +134,19 @@ export async function expectProjectSettingsName(page: Page, projectName: string)
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectWorkspace(
page: Page,
projectName: string,
workspaceName: string,
): Promise<void> {
await projectGroup(page, projectName)
.getByRole("button")
.filter({ hasText: workspaceName })
.click();
await expect(page).toHaveURL(/\/workspace\//);
}
export async function expectWorkspaceHost(page: Page, hostName: string): Promise<void> {
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"private": true,
"main": "index.ts",
"scripts": {
+230 -119
View File
@@ -16,7 +16,10 @@ import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
EditingTextInput as TextInput,
@@ -29,8 +32,11 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { createProjectIconTarget } from "@/projects/icon-target";
import { useProjectIcons } from "@/projects/icons";
import {
OverlayLayerProvider,
useGlobalWebOverlayLayer,
@@ -49,8 +55,14 @@ import {
} from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import {
COMMAND_CENTER_LINE_HEIGHT,
COMMAND_CENTER_LIST_METRICS,
COMMAND_CENTER_SECTION_GAP,
type CommandCenterListMetrics,
} from "./list-metrics";
import { useCommandCenterContributions } from "./provider";
import { filterAndRankWorkspaces } from "./workspace-search";
import {
@@ -89,42 +101,14 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
const leftAttention = left.requiresAttention ? 1 : 0;
const rightAttention = right.requiresAttention ? 1 : 0;
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
const leftRunning = left.status === "running" ? 1 : 0;
const rightRunning = right.status === "running" ? 1 : 0;
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
}
function compareWorkspacesByTitle(
left: CommandCenterWorkspaceResult,
right: CommandCenterWorkspaceResult,
): number {
const titleDelta = left.title.localeCompare(right.title, undefined, {
numeric: true,
sensitivity: "base",
});
return titleDelta || left.subtitle.localeCompare(right.subtitle);
}
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
/**
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
* collation sort over every workspace.
*
* The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps
* now refresh when agents or projects change rather than on every keystroke.
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
*/
function useBuiltInRows(open: boolean): {
workspaces: CommandCenterWorkspaceResult[];
@@ -134,11 +118,32 @@ function useBuiltInRows(open: boolean): {
const { agents } = useAggregatedAgents({ demand: open });
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
const iconTargets = useMemo(
() =>
open
? projects.flatMap((project) =>
project.hosts.flatMap((host) => {
const target = createProjectIconTarget({
projectViewKey: project.viewKey,
placement: { ...host, iconWorkingDir: host.repoRoot },
});
return target ? [target] : [];
}),
)
: [],
[open, projects],
);
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
return useMemo(() => {
if (!open) return { workspaces: [], agents: [] };
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
for (const project of projects) {
const workspaceProject = {
viewKey: project.viewKey,
name: project.projectName,
iconDataUri: iconDataByProjectViewKey.get(project.viewKey) ?? null,
};
for (const host of project.hosts) {
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
@@ -151,7 +156,9 @@ function useBuiltInRows(open: boolean): {
project.projectName,
workspace.currentBranch,
]),
project: workspaceProject,
changeRequestNumber: workspace.changeRequestNumber,
activityAt: workspace.statusEnteredAt,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
@@ -160,34 +167,31 @@ function useBuiltInRows(open: boolean): {
}
}
}
allWorkspaces.sort(compareWorkspacesByTitle);
const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
);
const agentRows = agents
.map<CommandCenterAgentResult>((agent) => {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: agent.title || t("shell.commandCenter.newAgent"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
workspaceTitle ?? shortenPath(agent.cwd),
formatTimeAgo(agent.lastActivityAt),
]),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
})
.sort((left, right) => sortAgents(left.agent, right.agent));
const agentRows = agents.map<CommandCenterAgentResult>((agent) => {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: agent.title || t("shell.commandCenter.newAgent"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
workspaceTitle ?? shortenPath(agent.cwd),
]),
activityAt: agent.lastActivityAt,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
});
return { workspaces: allWorkspaces, agents: agentRows };
}, [agents, open, projects, showHost, t]);
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
@@ -202,16 +206,14 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
band: PINNED_SECTION_BAND,
rank: 2,
title: t("shell.commandCenter.workspaces"),
results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle),
results: filterAndRankWorkspaces(rows.workspaces, query),
},
{
id: "agents",
band: PINNED_SECTION_BAND,
rank: 3,
title: t("shell.commandCenter.agents"),
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) =>
sortAgents(left.agent, right.agent),
),
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields),
},
];
}, [open, query, rows, t]);
@@ -236,7 +238,7 @@ interface CommandCenterState {
key(key: string): boolean;
}
function useCommandCenterState(): CommandCenterState {
function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
@@ -290,8 +292,9 @@ function useCommandCenterState(): CommandCenterState {
scope === "files"
? fileSections
: [...contributionSections, ...fileSections, ...builtInSections],
listMetrics,
),
[builtInSections, contributionSections, fileSections, scope],
[builtInSections, contributionSections, fileSections, listMetrics, scope],
);
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
@@ -381,11 +384,19 @@ function useCommandCenterState(): CommandCenterState {
interface ResultRowProps {
result: CommandCenterResult;
height: number;
query: string;
active: boolean;
onSelect(result: CommandCenterResult): void;
}
const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) {
const ResultRow = memo(function ResultRow({
result,
height,
query,
active,
onSelect,
}: ResultRowProps) {
const press = useCallback(() => onSelect(result), [onSelect, result]);
const choice =
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
@@ -402,15 +413,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
const style = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(result.kind === "agent" ||
result.kind === "workspace" ||
(result.kind === "contribution" &&
result.contribution.presentation.kind === "action" &&
Boolean(result.contribution.presentation.subtitle))) &&
styles.tallRow,
{ height },
(Boolean(hovered) || pressed || active) && styles.activeRow,
],
[active, result],
[active, height],
);
return (
<Pressable
@@ -424,12 +430,75 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
}
>
<ResultContent result={result} />
<ResultContent result={result} query={query} />
</Pressable>
);
});
function ResultContent({ result }: { result: CommandCenterResult }) {
/**
* Text with the query's terms marked. Marks use the same matcher options the rows were ranked
* with, so a mark always points at something that made the row match.
*/
function QueryHighlightedText({
query,
text,
...props
}: { query: string } & Omit<HighlightedTextProps, "ranges">) {
const ranges = useMemo(() => findHighlightRanges(query, text), [query, text]);
return <HighlightedText text={text} ranges={ranges} {...props} />;
}
/**
* The subtitle line of a workspace or agent row. The timestamp is its own text so a long branch
* truncates without pushing the time out of view, and so the time is never part of what the
* query matches against. It keeps itself current while the command center stays open.
*/
function SubtitleWithTimestamp({
query,
subtitle,
activityAt,
testID,
}: {
query: string;
subtitle: string;
activityAt: Date | null;
testID: string;
}) {
const timeAgo = useTimeAgo(activityAt);
return (
<View style={styles.subtitleLine}>
<QueryHighlightedText
query={query}
text={subtitle}
style={[styles.subtitle, styles.subtitleText]}
numberOfLines={1}
testID={testID}
/>
{timeAgo ? (
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-result-timestamp">
{/* Non-breaking: web collapses a leading plain space in its own flex item. */}
{subtitle ? `\u00a0· ${timeAgo}` : timeAgo}
</Text>
) : null}
</View>
);
}
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
return (
<ProjectIconView
iconDataUri={project.iconDataUri}
initial={initial}
projectViewKey={project.viewKey}
size={16}
textStyle={styles.projectIconFallbackText}
/>
);
}
function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) {
if (result.kind === "file") {
return (
<View style={styles.rowContent}>
@@ -438,13 +507,16 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
<MaterialFileIcon fileName={result.title} size={16} />
</View>
<Text style={styles.fileLine} numberOfLines={1} testID="command-center-file-line">
<Text style={styles.fileName} testID="command-center-file-name">
{result.title}
</Text>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.fileName}
testID="command-center-file-name"
/>
{result.subtitle ? (
<Text style={styles.filePath} testID="command-center-file-path">
{" "}
{result.subtitle}
<QueryHighlightedText query={query} text={result.subtitle} />
</Text>
) : null}
</Text>
@@ -465,12 +537,18 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
/>
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{result.title}
</Text>
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-agent-subtitle">
{result.subtitle}
</Text>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-agent-subtitle"
/>
</View>
</View>
</View>
@@ -481,20 +559,28 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
return (
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<ThemedFolder size={16} strokeWidth={2.2} />
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
<View
style={styles.iconSlot}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
>
<WorkspaceProjectIcon project={result.project} />
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{result.title}
</Text>
<Text
style={styles.subtitle}
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-workspace-subtitle"
>
{result.subtitle}
</Text>
/>
</View>
</View>
</View>
@@ -512,11 +598,19 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
</View>
) : null}
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{presentation.title}
</Text>
<QueryHighlightedText
query={query}
text={presentation.title}
style={styles.title}
numberOfLines={1}
/>
{presentation.subtitle ? (
<Text style={styles.subtitle}>{presentation.subtitle}</Text>
<QueryHighlightedText
query={query}
text={presentation.subtitle}
style={styles.subtitle}
numberOfLines={1}
/>
) : null}
</View>
</View>
@@ -541,14 +635,14 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
style={styles.breadcrumbPart}
>
{index > 0 ? <ThemedChevronRight size={13} strokeWidth={2} /> : null}
<Text
<QueryHighlightedText
query={query}
text={part}
style={
index === presentation.path.length - 1 ? styles.title : styles.breadcrumbGroup
}
numberOfLines={1}
>
{part}
</Text>
/>
</View>
))}
</View>
@@ -563,11 +657,9 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
}
function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "section" }> }) {
let sizeStyle = styles.dividerSection;
if (row.title && row.divider) sizeStyle = styles.dividedSection;
if (row.title && !row.divider) sizeStyle = styles.titledSection;
const style = useMemo(() => [styles.section, { height: row.height }], [row.height]);
return (
<View style={sizeStyle}>
<View style={style}>
{row.divider ? <View style={styles.sectionDivider} /> : null}
{row.title ? <Text style={styles.sectionLabel}>{row.title}</Text> : null}
</View>
@@ -576,8 +668,10 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
export function CommandCenter() {
const { t } = useTranslation();
const state = useCommandCenterState();
const isCompact = useIsCompactFormFactor();
const state = useCommandCenterState(
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
);
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
@@ -644,11 +738,13 @@ export function CommandCenter() {
) : (
<ResultRow
result={item.result}
height={item.height}
query={state.query}
active={item.result.id === state.activeId}
onSelect={state.select}
/>
),
[state.activeId, state.select],
[state.activeId, state.query, state.select],
);
const getItemLayout = useCallback(
(_data: ArrayLike<CommandCenterListRow> | null | undefined, index: number) => ({
@@ -901,20 +997,21 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: "500",
},
results: { flex: 1 },
// Sections and rows take their height from list-metrics; these styles only place content in it.
section: { justifyContent: "flex-end" },
sectionLabel: {
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
paddingBottom: COMMAND_CENTER_SECTION_GAP,
fontSize: theme.fontSize.sm,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.sectionLabel,
color: theme.colors.foregroundMuted,
},
sectionDivider: {
height: 1,
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
marginVertical: COMMAND_CENTER_SECTION_GAP,
backgroundColor: theme.colors.border,
},
row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] },
tallRow: { height: 56 },
row: { paddingHorizontal: theme.spacing[4], justifyContent: "center" },
activeRow: { backgroundColor: theme.colors.surface1 },
rowContent: {
flexDirection: "row",
@@ -933,14 +1030,31 @@ const styles = StyleSheet.create((theme) => ({
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 18,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.title,
flexShrink: 1,
},
fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.base, lineHeight: 20 },
fileLine: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.base,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine,
},
fileName: { color: theme.colors.foreground },
filePath: { color: theme.colors.foregroundMuted },
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: 16 },
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.subtitle,
},
subtitleLine: { flexDirection: "row", minWidth: 0 },
subtitleText: { flexShrink: 1, minWidth: 0 },
iconSlot: {
width: 16,
height: COMMAND_CENTER_LINE_HEIGHT.singleLine,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: { fontSize: 9 },
rowShortcut: { flexShrink: 0 },
breadcrumb: {
flex: 1,
@@ -958,7 +1072,7 @@ const styles = StyleSheet.create((theme) => ({
breadcrumbGroup: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 20,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine,
flexShrink: 0,
},
emptyText: {
@@ -978,7 +1092,4 @@ const styles = StyleSheet.create((theme) => ({
},
sheetBackground: { backgroundColor: theme.colors.surface0 },
sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
titledSection: { height: 32, justifyContent: "flex-end" },
dividedSection: { height: 49, justifyContent: "flex-end" },
dividerSection: { height: 17, justifyContent: "flex-end" },
}));
@@ -0,0 +1,53 @@
import { SPACING } from "@/styles/theme";
/**
* Every vertical size in the Command Center list, in one place.
*
* The list is virtualized, so it needs each row's height before rendering it. Rather than keep
* those numbers in sync with the styles by hand, rows render at exactly the height computed here
* and center their content. The styles read the line heights below, so this arithmetic is the
* layout.
*
* A layout chooses one thing: the inset above and below a row's text. Both layouts share the same
* shape, so nothing downstream asks which one it got.
*/
export const COMMAND_CENTER_LINE_HEIGHT = {
/** A row with no subtitle. Also the icon slot, so the icon centers on the title. */
singleLine: 20,
title: 18,
subtitle: 16,
sectionLabel: 16,
} as const;
/** Space around a section's label and around the rule between sections. */
export const COMMAND_CENTER_SECTION_GAP = SPACING[2];
const SECTION_DIVIDER_THICKNESS = 1;
export interface CommandCenterListMetrics {
singleLineRow: number;
twoLineRow: number;
/** A section label and the gaps around it. */
sectionTitle: number;
/** The rule between two sections and the gaps around it. */
sectionDivider: number;
}
function listMetrics(rowInset: number): CommandCenterListMetrics {
const line = COMMAND_CENTER_LINE_HEIGHT;
return {
singleLineRow: rowInset + line.singleLine + rowInset,
twoLineRow: rowInset + line.title + line.subtitle + rowInset,
sectionTitle: COMMAND_CENTER_SECTION_GAP + line.sectionLabel + COMMAND_CENTER_SECTION_GAP,
sectionDivider:
COMMAND_CENTER_SECTION_GAP + SECTION_DIVIDER_THICKNESS + COMMAND_CENTER_SECTION_GAP,
};
}
/**
* Desktop is dense for a pointer. Compact keeps single-line rows at the 44pt touch height that
* `density.rowHeight` uses on compact layouts.
*/
export const COMMAND_CENTER_LIST_METRICS = {
desktop: listMetrics(SPACING[1.5]),
compact: listMetrics(SPACING[3]),
} as const satisfies Record<string, CommandCenterListMetrics>;
+103 -54
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { CommandCenterContribution } from "./contributions";
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
import {
buildContributionSections,
filterAndRankBuiltInResults,
@@ -101,17 +102,28 @@ function autoModeChoice(): CommandCenterContribution {
});
}
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
function workspace(
id: string,
title = id,
subtitle = "host",
activityAt: Date | null = null,
): CommandCenterWorkspaceResult {
return {
kind: "workspace",
id,
title,
subtitle,
project: { viewKey: "project", name: "project", iconDataUri: null },
changeRequestNumber: null,
activityAt,
run: () => undefined,
};
}
function minutesAgo(minutes: number): Date {
return new Date(Date.UTC(2026, 9, 18, 12, 0) - minutes * 60_000);
}
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
const ids: string[] = [];
for (const section of sections) {
@@ -120,6 +132,8 @@ function sectionResultIds(sections: ReturnType<typeof buildContributionSections>
return ids;
}
const metrics = COMMAND_CENTER_LIST_METRICS.desktop;
describe("Command Center result projection", () => {
it("query-gates model choices and creates one flat row index", () => {
const contributions = [
@@ -130,16 +144,19 @@ describe("Command Center result projection", () => {
expect(sectionResultIds(emptySections)).toEqual(["settings"]);
const sections = buildContributionSections(contributions, "o");
const projection = projectCommandCenterRows([
...sections,
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("workspace:1")],
},
]);
const projection = projectCommandCenterRows(
[
...sections,
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("workspace:1")],
},
],
metrics,
);
expect(projection.rows.map((row) => row.key)).toEqual([
"section:models",
"opus",
@@ -147,7 +164,12 @@ describe("Command Center result projection", () => {
"workspace:1",
]);
expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3);
expect(projection.offsets).toEqual([0, 32, 68, 117]);
expect(projection.offsets).toEqual([
0,
metrics.sectionTitle,
metrics.sectionTitle + metrics.singleLineRow,
metrics.sectionTitle + metrics.singleLineRow + metrics.sectionDivider + metrics.sectionTitle,
]);
});
it("shows query-only actions only when their group matches a search", () => {
@@ -177,15 +199,18 @@ describe("Command Center result projection", () => {
it("keeps keyboard selection aligned with rows beyond the render window", () => {
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
const projection = projectCommandCenterRows([
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 0,
title: "Workspaces",
results: workspaces,
},
]);
const projection = projectCommandCenterRows(
[
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 0,
title: "Workspaces",
results: workspaces,
},
],
metrics,
);
let activeId: string | null = workspaces[0].id;
for (let index = 0; index < 150; index += 1) {
@@ -195,7 +220,7 @@ describe("Command Center result projection", () => {
const targetId = workspaces[150].id;
expect(activeId).toBe(targetId);
expect(projection.rowIndexByResultId.get(targetId)).toBe(151);
expect(projection.offsets[151]).toBe(32 + 150 * 56);
expect(projection.offsets[151]).toBe(metrics.sectionTitle + 150 * metrics.twoLineRow);
});
});
@@ -248,7 +273,7 @@ describe("Command Center relevance ordering", () => {
schedulesAction(),
action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }),
];
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""));
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""), metrics);
// Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature.
expect(projection.selectableResults.map((result) => result.id)).toEqual([
@@ -301,16 +326,19 @@ describe("Command Center relevance ordering", () => {
});
it("keeps Workspaces below contributions even on a perfect match", () => {
const projection = projectCommandCenterRows([
...buildContributionSections([schedulesAction()], "auto"),
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("auto")],
},
]);
const projection = projectCommandCenterRows(
[
...buildContributionSections([schedulesAction()], "auto"),
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("auto")],
},
],
metrics,
);
expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]);
});
@@ -324,7 +352,7 @@ describe("Command Center relevance ordering", () => {
title: "Workspaces",
results: [workspace("auto")],
};
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]);
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()], metrics);
expect(projection.selectableResults.map((result) => result.id)).toEqual([
"mode:auto",
@@ -461,15 +489,19 @@ describe("filterAndRankBuiltInResults", () => {
visible: [row.title, row.subtitle],
hidden: [],
});
const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) =>
left.title.localeCompare(right.title);
it("caps an empty query and preserves the caller's order", () => {
const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`));
it("shows the five most recent rows for an empty query", () => {
const rows = Array.from({ length: 8 }, (_, index) =>
workspace(`w${index}`, `w${index}`, "host", minutesAgo(8 - index)),
);
expect(
filterAndRankBuiltInResults(rows, "", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["w0", "w1", "w2", "w3", "w4"]);
expect(filterAndRankBuiltInResults(rows, "", visibleOnly).map((row) => row.id)).toEqual([
"w7",
"w6",
"w5",
"w4",
"w3",
]);
});
it("puts the best match first even when it sorts last alphabetically", () => {
@@ -480,9 +512,9 @@ describe("filterAndRankBuiltInResults", () => {
];
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
expect(
filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["c"]);
expect(filterAndRankBuiltInResults(rows, "lab des", visibleOnly).map((row) => row.id)).toEqual([
"c",
]);
});
it("ranks a title match above a match that only the hidden field reached", () => {
@@ -492,24 +524,41 @@ describe("filterAndRankBuiltInResults", () => {
? { visible: [row.title], hidden: ["deploy"] }
: { visible: [row.title], hidden: [] };
expect(
filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id),
).toEqual(["title-hit", "hidden-hit"]);
expect(filterAndRankBuiltInResults(rows, "deploy", fields).map((row) => row.id)).toEqual([
"title-hit",
"hidden-hit",
]);
});
it("breaks an equal score with the caller's tiebreak, not with match order", () => {
// Both titles are an exact prefix match for "task", so only the tiebreak separates them.
const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")];
it("orders rows in the same match tier by recency, not by where the word sits", () => {
// Both are whole-word matches for "usage"; the older one finds it earlier in the title.
const rows = [
workspace("older", "Deliver sidebar usage", "host", minutesAgo(60)),
workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)),
];
expect(
filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["first", "second"]);
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
"newer",
"older",
]);
});
it("keeps a better match tier above a more recent weaker match", () => {
const rows = [
workspace("subsequence", "Fix stuck toast", "fix/updating-messages", minutesAgo(1)),
workspace("whole-word", "Usage hover card", "host", minutesAgo(600)),
];
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
"whole-word",
"subsequence",
]);
});
it("drops a row when one token matches nothing", () => {
const rows = [workspace("a", "Label as Design")];
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly, byTitle)).toEqual([]);
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]);
});
});
+47 -19
View File
@@ -5,6 +5,7 @@ import {
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
import type { CommandCenterContribution } from "./contributions";
import type { CommandCenterListMetrics } from "./list-metrics";
/** Sections that reorder by how well they match the query. */
export const CONTRIBUTION_SECTION_BAND = 0;
@@ -43,13 +44,21 @@ export function compareCommandCenterScores(
return left.fieldRank - right.fieldRank || compareMatchScores(left.match, right.match);
}
export interface CommandCenterWorkspaceProject {
viewKey: string;
name: string;
iconDataUri: string | null;
}
export interface CommandCenterWorkspaceResult {
kind: "workspace";
id: string;
title: string;
subtitle: string;
project: CommandCenterWorkspaceProject;
/** PR/MR number backing this workspace, so a number query can match it exactly. */
changeRequestNumber: number | null;
activityAt: Date | null;
run(): void;
}
@@ -59,6 +68,7 @@ export interface CommandCenterAgentResult {
agent: AggregatedAgent;
title: string;
subtitle: string;
activityAt: Date | null;
run(): void;
}
@@ -169,13 +179,12 @@ function contributionSearchFields(
return { visible, hidden: contribution.keywords };
}
function resultHeight(result: CommandCenterResult): number {
if (result.kind === "workspace" || result.kind === "agent") return 56;
if (result.kind === "file") return 36;
if (result.contribution.presentation.kind === "action") {
return result.contribution.presentation.subtitle ? 56 : 36;
}
return 36;
/** Whether the row draws a second, subtitle line under its title. */
function hasSubtitleLine(result: CommandCenterResult): boolean {
if (result.kind === "workspace" || result.kind === "agent") return true;
if (result.kind === "file") return false;
const presentation = result.contribution.presentation;
return presentation.kind === "action" && Boolean(presentation.subtitle);
}
/**
@@ -203,22 +212,42 @@ function resultScore(result: CommandCenterResult): CommandCenterScore | undefine
/** Rows shown when nothing has been typed, per pinned category. */
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
/** A built-in row (workspace or agent) that orders by how recently it saw activity. */
export interface CommandCenterRecentRow {
activityAt: Date | null;
}
/** Most recent first. A row with no timestamp sorts after every row that has one. */
export function compareByRecency(left: CommandCenterRecentRow, right: CommandCenterRecentRow) {
return (right.activityAt?.getTime() ?? 0) - (left.activityAt?.getTime() ?? 0);
}
/**
* Rank built-in rows by the kind of match they made: which fields matched, then the matcher's
* tier. Offset and spread are deliberately left out. They only say where in the title the word
* happened to sit, so "usage" in "Deliver sidebar usage" and in "expose usage data" would be
* ordered by sentence position. Leaving them out makes those rows equal, and recency decides.
*/
export function compareBuiltInScores(left: CommandCenterScore, right: CommandCenterScore): number {
return left.fieldRank - right.fieldRank || left.match.tier - right.match.tier;
}
/**
* Filter and rank already-built rows. Rows in, rows out — building them needs translations,
* navigation callbacks and the project tree, none of which matching cares about, so that stays
* with the caller and this stays testable without mounting React.
*
* `tiebreak` is the caller's own ordering (alphabetical for workspaces, status and recency for
* agents). It decides equal-scoring rows, so an agent awaiting input still outranks an idle one
* that matched exactly as well.
* Rows rank by `compareBuiltInScores`; rows that rank equally, including every row of an empty
* query, go most recent first.
*/
export function filterAndRankBuiltInResults<Row>(
export function filterAndRankBuiltInResults<Row extends CommandCenterRecentRow>(
rows: readonly Row[],
query: string,
fieldsOf: (row: Row) => CommandCenterSearchFields,
tiebreak: (left: Row, right: Row) => number,
): Row[] {
if (!query.trim()) return rows.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
if (!query.trim()) {
return [...rows].sort(compareByRecency).slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
}
const scored: { row: Row; score: CommandCenterScore }[] = [];
for (const row of rows) {
@@ -227,7 +256,7 @@ export function filterAndRankBuiltInResults<Row>(
}
scored.sort(
(left, right) =>
compareCommandCenterScores(left.score, right.score) || tiebreak(left.row, right.row),
compareBuiltInScores(left.score, right.score) || compareByRecency(left.row, right.row),
);
return scored.map((entry) => entry.row);
}
@@ -293,6 +322,7 @@ export function buildContributionSections(
export function projectCommandCenterRows(
sections: readonly CommandCenterResultSection[],
metrics: CommandCenterListMetrics,
): CommandCenterListProjection {
const populated = sections
.filter((section) => section.results.length > 0)
@@ -305,10 +335,8 @@ export function projectCommandCenterRows(
for (const [sectionIndex, section] of populated.entries()) {
const divider = sectionIndex > 0;
let sectionHeight = 0;
if (section.title && divider) sectionHeight = 49;
if (section.title && !divider) sectionHeight = 32;
if (!section.title && divider) sectionHeight = 17;
const sectionHeight =
(divider ? metrics.sectionDivider : 0) + (section.title ? metrics.sectionTitle : 0);
if (sectionHeight > 0) {
offsets.push(offset);
rows.push({
@@ -321,7 +349,7 @@ export function projectCommandCenterRows(
offset += sectionHeight;
}
for (const result of section.results) {
const height = resultHeight(result);
const height = hasSubtitleLine(result) ? metrics.twoLineRow : metrics.singleLineRow;
offsets.push(offset);
rowIndexByResultId.set(result.id, rows.length);
rows.push({ kind: "result", key: result.id, result, height });
@@ -7,21 +7,22 @@ function workspace(input: {
title?: string;
subtitle?: string;
changeRequestNumber?: number | null;
activityAt?: Date | null;
}): CommandCenterWorkspaceResult {
return {
kind: "workspace",
id: input.id,
title: input.title ?? input.id,
subtitle: input.subtitle ?? "",
project: { viewKey: "project", name: "project", iconDataUri: null },
changeRequestNumber: input.changeRequestNumber ?? null,
activityAt: input.activityAt ?? null,
run: () => undefined,
};
}
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
return filterAndRankWorkspaces(rows, query, (left, right) => left.id.localeCompare(right.id)).map(
(row) => row.id,
);
return filterAndRankWorkspaces(rows, query).map((row) => row.id);
}
describe("filterAndRankWorkspaces", () => {
@@ -79,4 +80,18 @@ describe("filterAndRankWorkspaces", () => {
const text = workspace({ id: "a-text", title: "Migration 42" });
expect(ids([text, subject], "42")).toEqual(["payments", "a-text"]);
});
it("orders workspaces in the same match tier by recency", () => {
const older = workspace({
id: "older",
title: "Usage hover card",
activityAt: new Date("2026-10-01T00:00:00Z"),
});
const newer = workspace({
id: "newer",
title: "Deliver sidebar usage",
activityAt: new Date("2026-10-17T00:00:00Z"),
});
expect(ids([older, newer], "usage")).toEqual(["newer", "older"]);
});
});
@@ -1,5 +1,6 @@
import {
compareCommandCenterScores,
compareBuiltInScores,
compareByRecency,
filterAndRankBuiltInResults,
scoreSearchFields,
type CommandCenterScore,
@@ -33,29 +34,22 @@ function searchFields(workspace: CommandCenterWorkspaceResult) {
return { visible: [workspace.title, workspace.subtitle], hidden: [] };
}
function compareScoredWorkspaces(
left: ScoredWorkspace,
right: ScoredWorkspace,
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
): number {
function compareScoredWorkspaces(left: ScoredWorkspace, right: ScoredWorkspace): number {
if (left.changeRequestHit !== right.changeRequestHit) {
return left.changeRequestHit ? -1 : 1;
}
if (left.score && right.score) {
const scoreDelta = compareCommandCenterScores(left.score, right.score);
const scoreDelta = compareBuiltInScores(left.score, right.score);
if (scoreDelta !== 0) return scoreDelta;
}
return tiebreak(left.workspace, right.workspace);
return compareByRecency(left.workspace, right.workspace);
}
export function filterAndRankWorkspaces(
workspaces: readonly CommandCenterWorkspaceResult[],
query: string,
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
): CommandCenterWorkspaceResult[] {
if (!query.trim()) {
return filterAndRankBuiltInResults(workspaces, query, searchFields, tiebreak);
}
if (!query.trim()) return filterAndRankBuiltInResults(workspaces, query, searchFields);
const changeRequestNumber = parseChangeRequestQuery(query);
const matches: ScoredWorkspace[] = [];
@@ -65,6 +59,6 @@ export function filterAndRankWorkspaces(
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit });
}
matches.sort((left, right) => compareScoredWorkspaces(left, right, tiebreak));
matches.sort(compareScoredWorkspaces);
return matches.map((match) => match.workspace);
}
+7 -4
View File
@@ -48,6 +48,8 @@ const DATE_SECTION_ORDER = [
"older",
] as const satisfies readonly DateSectionKey[];
const HISTORY_MATCH_OPTIONS = { typoTolerant: true } as const;
type FlatListItem =
| { type: "header"; key: string; section: DateSectionKey }
| { type: "agent"; key: string; agent: AggregatedAgent };
@@ -190,10 +192,11 @@ function SessionRow({
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const ranges = useMemo(
() => ({
workspace: findHighlightRanges(search ?? "", workspaceName),
title: findHighlightRanges(search ?? "", agent.title ?? ""),
branch: findHighlightRanges(search ?? "", branch),
project: findHighlightRanges(search ?? "", projectName),
// History search matches typo-tolerantly on the daemon, so the marks do too.
workspace: findHighlightRanges(search ?? "", workspaceName, HISTORY_MATCH_OPTIONS),
title: findHighlightRanges(search ?? "", agent.title ?? "", HISTORY_MATCH_OPTIONS),
branch: findHighlightRanges(search ?? "", branch, HISTORY_MATCH_OPTIONS),
project: findHighlightRanges(search ?? "", projectName, HISTORY_MATCH_OPTIONS),
}),
[search, workspaceName, agent.title, branch, projectName],
);
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
import { isWeb } from "@/constants/platform";
import { density } from "@/styles/density";
/** Stacks Markdown blocks rendered as separate views with the same gap the text puts between its blocks. */
export function MarkdownBlockStack({
imageSources,
children,
}: {
imageSources?: Record<string, string>;
children: ReactNode;
}) {
return (
<View
style={styles.stack}
dataSet={isWeb && imageSources ? markdownCopyImageSourcesDataSet(imageSources) : undefined}
>
{children}
</View>
);
}
const styles = StyleSheet.create({
stack: { gap: density.blockGap },
});
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
import { Pressable, View, type ViewStyle } from "react-native";
import { Code, Maximize2, Workflow } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -68,15 +68,6 @@ function MermaidFenceHostImpl({
],
[openFullscreen, showSourcePress, t],
);
const sourceView = useMemo(() => {
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
const margins: ViewStyle = {
marginTop: marginTop ?? marginVertical,
marginBottom: marginBottom ?? marginVertical,
};
const text: TextStyle = sourceTextStyle;
return { container: [margins, sourceContainerStyle], text };
}, [textStyle]);
const diagramStyle = useMemo(
() => [
getDiagramBoxStyle(textStyle),
@@ -90,19 +81,17 @@ function MermaidFenceHostImpl({
[visible],
);
const sourceVisible = !diagramVisible;
const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
const sourceTextStyle = showSource ? sourceView.text : textStyle;
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
return (
<>
{sourceVisible ? (
<View style={sourceContainer}>
<View style={sourceContainerStyle}>
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={sourceTextStyle}
textStyle={textStyle}
/>
{showSource && canShowDiagram ? (
<Pressable
@@ -6,9 +6,6 @@ export function getDiagramBoxStyle(style: TextStyle): ViewStyle {
borderColor: style.borderColor,
borderRadius: style.borderRadius,
borderWidth: style.borderWidth,
marginBottom: style.marginBottom,
marginTop: style.marginTop,
marginVertical: style.marginVertical,
padding: style.padding,
};
}
@@ -1,18 +0,0 @@
import React, { type ReactElement } from "react";
import { View } from "react-native";
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
import { isWeb } from "@/constants/platform";
export function MarkdownRendererFrame({
imageSources,
children,
}: {
imageSources: Record<string, string>;
children: ReactElement;
}) {
return (
<View dataSet={isWeb ? markdownCopyImageSourcesDataSet(imageSources) : undefined}>
{children}
</View>
);
}
@@ -307,7 +307,7 @@ describe("shared Markdown theme colours", () => {
["dark", darkTheme],
["light", lightTheme],
] as const)("uses %s theme colours for tables and task labels", (_name, theme) => {
const { markdownStyle: style } = createMarkdownStyles(theme);
const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
expect(style.table).toMatchObject({
color: theme.colors.foreground,
headerTextColor: theme.colors.foreground,
@@ -337,7 +337,7 @@ it.each([
] as const)(
"uses %s theme colours for quotes, admonitions, code, links and dividers",
(_name, theme) => {
const { markdownStyle: style } = createMarkdownStyles(theme);
const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
expect(style.blockquote).toMatchObject({
color: `${theme.colors.foreground}cc`,
backgroundColor: theme.colors.surface1,
@@ -364,7 +364,7 @@ it.each([
);
it.each([darkTheme, lightTheme])("supplies the theme selection colour to the renderer", (theme) => {
expect(createMarkdownStyles(theme)).toHaveProperty(
expect(createMarkdownStyles(theme, { blockGap: 16 })).toHaveProperty(
"selectionColor",
theme.colors.terminal.selectionBackground,
);
@@ -431,7 +431,7 @@ it("preserves backslashes and decodes entities inside HTML code", () => {
it.each([false, true])(
"caps inline images to the paragraph line height (compact: %s)",
(compact) => {
const { markdownStyle } = createMarkdownStyles(darkTheme, compact);
const { markdownStyle } = createMarkdownStyles(darkTheme, { compact, blockGap: 16 });
expect(markdownStyle.inlineImage?.size).toBe(markdownStyle.paragraph?.lineHeight);
},
);
@@ -9,7 +9,8 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
import { MarkdownRendererFrame } from "./frame";
import { density, resolveDensity } from "@/styles/density";
import { MarkdownBlockStack } from "./block-stack";
import { WEB_RENDERERS } from "./renderers";
import { createMarkdownStyles } from "./styles";
import { PENDING_MARKDOWN_IMAGE } from "./source";
@@ -21,8 +22,11 @@ import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fen
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
const regularStyle = (theme: Theme) => createMarkdownStyles(theme);
const compactStyle = (theme: Theme) => createMarkdownStyles(theme, true);
// Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it.
const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) =>
createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) });
const regularStyle = markdownStyle(false);
const compactStyle = markdownStyle(true);
const WEB_CONTAINER = { userSelect: "text" as const };
export interface MarkdownRendererProps extends MarkdownImageContext {
@@ -43,21 +47,20 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
);
if (!streaming || boundary === 0) return <MarkdownPart {...props} />;
return (
<>
<MarkdownPart {...props} text={props.text.slice(0, boundary)} continues />
<MarkdownBlockStack>
<MarkdownPart {...props} text={props.text.slice(0, boundary)} />
<MarkdownPart {...props} text={props.text.slice(boundary)} />
</>
</MarkdownBlockStack>
);
});
const MarkdownPart = memo(function MarkdownPart({
text,
continues = false,
compact = false,
streaming = false,
onLinkPress,
...imageContext
}: MarkdownRendererProps & { continues?: boolean }) {
}: MarkdownRendererProps) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const client =
@@ -86,35 +89,35 @@ const MarkdownPart = memo(function MarkdownPart({
isWeb ? [{ kind: "markdown", id: "0", source: markdown }] : splitMermaidFences(markdown),
[markdown],
);
const content = (
return (
<MermaidStreamingContext.Provider value={streaming}>
{segments.map((segment) =>
segment.kind === "mermaid" ? (
<MermaidFence key={`${segment.kind}:${segment.id}`} code={segment.source} />
) : (
<ThemedMarkdown
key={`${segment.kind}:${segment.id}`}
markdown={segment.source}
containerStyle={isWeb ? WEB_CONTAINER : undefined}
uniProps={compact ? compactStyle : regularStyle}
flavor="github"
selectable
allowTrailingMargin={continues && segment === segments[segments.length - 1]}
md4cFlags={MD4C_FLAGS}
linkRecognition={linkRecognition}
enableTaskListItemToggle={false}
enableLinkPreview={false}
enableBlockContextMenu={false}
// JS reveal owns pacing; native fade animation is a separate experiment.
streamingAnimation={false}
onLinkPress={pressLink}
onImagePress={pressImage}
renderers={isWeb ? WEB_RENDERERS : undefined}
/>
),
)}
<MarkdownBlockStack imageSources={imageSources}>
{segments.map((segment) =>
segment.kind === "mermaid" ? (
<MermaidFence key={`${segment.kind}:${segment.id}`} code={segment.source} />
) : (
<ThemedMarkdown
key={`${segment.kind}:${segment.id}`}
markdown={segment.source}
containerStyle={isWeb ? WEB_CONTAINER : undefined}
uniProps={compact ? compactStyle : regularStyle}
flavor="github"
selectable
md4cFlags={MD4C_FLAGS}
linkRecognition={linkRecognition}
enableTaskListItemToggle={false}
enableLinkPreview={false}
enableBlockContextMenu={false}
// JS reveal owns pacing; native fade animation is a separate experiment.
streamingAnimation={false}
onLinkPress={pressLink}
onImagePress={pressImage}
renderers={isWeb ? WEB_RENDERERS : undefined}
/>
),
)}
</MarkdownBlockStack>
{image ? <AttachmentLightbox source={image} onClose={closeImage} /> : null}
</MermaidStreamingContext.Provider>
);
return <MarkdownRendererFrame imageSources={imageSources}>{content}</MarkdownRendererFrame>;
});
@@ -4,23 +4,23 @@ import { isWeb } from "@/constants/platform";
export function createMarkdownStyles(
theme: Theme,
compact = false,
{ compact = false, blockGap }: { compact?: boolean; blockGap: number },
): { markdownStyle: MarkdownStyle; selectionColor: string } {
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
// Every block is followed by the same gap; the library drops it after the last block.
const block = { marginTop: 0, marginBottom: blockGap };
const paragraph = {
fontSize,
fontFamily: theme.fontFamily.ui,
lineHeight: Math.round(fontSize * 1.4),
color: theme.colors.foreground,
marginTop: 0,
marginBottom: theme.spacing[3],
...block,
};
const heading = (scale: number) => ({
...paragraph,
fontSize: Math.round(fontSize * scale),
lineHeight: Math.round(fontSize * scale * 1.3),
fontWeight: "bold",
marginTop: theme.spacing[3],
});
return {
selectionColor: theme.colors.terminal.selectionBackground,
@@ -93,8 +93,8 @@ export function createMarkdownStyles(
borderColor: theme.colors.foregroundMuted,
},
inlineImage: { size: paragraph.lineHeight },
image: { height: 320, resizeMode: "contain", borderRadius: 8 },
thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 },
image: { ...block, height: 320, resizeMode: "contain", borderRadius: 8 },
thematicBreak: { ...block, color: theme.colors.border, height: 1 },
},
};
}
@@ -6,18 +6,15 @@ import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { isWeb } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { NestedSettingsRows, useSettingsRowLayout } from "./row-density";
const ThemedChevron = withUnistyles(ChevronRight);
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
interface SettingsCollapsibleRowProps {
label: string;
/** A short status shown before the chevron, so the row says something while collapsed. */
summary?: string;
defaultExpanded?: boolean;
testID?: string;
/** The rows revealed underneath, separated like the rows of a `SettingsCard` and laid out as nested rows. */
/** The rows revealed underneath, separated like the rows of a `SettingsCard`. */
children: ReactNode;
}
@@ -28,7 +25,6 @@ interface SettingsCollapsibleRowProps {
*/
export function SettingsCollapsibleRow({
label,
summary,
defaultExpanded = false,
testID,
children,
@@ -41,14 +37,13 @@ export function SettingsCollapsibleRow({
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
[expanded],
);
const layout = useSettingsRowLayout();
const headerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
layout.row,
settingsStyles.row,
hovered && styles.headerHovered,
pressed && styles.headerPressed,
],
[layout],
[],
);
const chevronStyle = useMemo(
() => [styles.chevron, expanded && styles.chevronExpanded],
@@ -60,7 +55,7 @@ export function SettingsCollapsibleRow({
<Pressable
{...webExpandedProps}
accessibilityRole="button"
accessibilityLabel={summary ? `${label}, ${summary}` : label}
accessibilityLabel={label}
accessibilityState={accessibilityState}
onPress={toggle}
style={headerStyle}
@@ -69,9 +64,8 @@ export function SettingsCollapsibleRow({
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
<>
<View style={settingsStyles.rowContent}>
<Text style={layout.title}>{label}</Text>
<Text style={settingsStyles.rowTitle}>{label}</Text>
</View>
{summary ? <Text style={styles.summary}>{summary}</Text> : null}
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
@@ -81,34 +75,18 @@ export function SettingsCollapsibleRow({
</>
)}
</Pressable>
{expanded ? (
<View style={styles.children}>
<NestedSettingsRows>
{Children.toArray(children).map((child, index) => (
<View
key={isValidElement(child) ? child.key : index}
style={settingsStyles.rowBorder}
>
{child}
</View>
))}
</NestedSettingsRows>
</View>
) : null}
{expanded
? Children.toArray(children).map((child, index) => (
<View key={isValidElement(child) ? child.key : index} style={settingsStyles.rowBorder}>
{child}
</View>
))
: null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
summary: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginRight: theme.spacing[2],
},
// The revealed rows keep the card's rails; the tint is what groups them under their header.
children: {
backgroundColor: theme.colors.surface0,
},
headerHovered: {
backgroundColor: theme.colors.surface2,
},
@@ -1,4 +1,4 @@
import type { ComponentType, ReactNode } from "react";
import type { ReactNode } from "react";
import { Pressable, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { Info } from "lucide-react-native";
@@ -6,13 +6,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ICON_SIZE, type Theme } from "@/styles/theme";
type TipIconComponent = ComponentType<{ size: number; color: string }>;
function TipIcon({ icon: Icon, color }: { icon: TipIconComponent; color: string }) {
return <Icon size={ICON_SIZE.sm} color={color} />;
}
const ThemedTipIcon = withUnistyles(TipIcon);
const ThemedInfo = withUnistyles(Info);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -20,37 +14,27 @@ interface SettingsInfoTipProps {
/** Header the tip belongs to; used for the accessibility label. */
title: string;
info: ReactNode;
/** Defaults to the info icon. */
icon?: TipIconComponent;
/** Defaults to "About {title}". */
accessibilityLabel?: string;
testID?: string;
}
/**
* The one way a settings group or section explains itself: an info icon on the
* header that opens a tooltip. Never a paragraph under the header — see
* docs/design.md §7. Rows use it the same way to keep secondary details on one line.
* docs/design.md §7.
*/
export function SettingsInfoTip({
title,
info,
icon = Info,
accessibilityLabel,
testID,
}: SettingsInfoTipProps) {
export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) {
const { t } = useTranslation();
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? t("settings.groupInfo", { title })}
accessibilityLabel={t("settings.groupInfo", { title })}
testID={testID}
hitSlop={8}
style={styles.button}
>
<ThemedTipIcon icon={icon} uniProps={mutedIconMapping} />
<ThemedInfo size={ICON_SIZE.sm} uniProps={mutedIconMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="start" offset={8}>
+7 -16
View File
@@ -24,12 +24,9 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/component
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import { ControlSizeProvider } from "@/components/ui/control-size";
import { useSettingsRowLayout } from "./row-density";
export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section";
export { SettingsCollapsibleRow } from "./collapsible-row";
export { SettingsInfoTip } from "./headings/settings-info-tip";
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
hint?: ReactNode;
@@ -60,32 +57,26 @@ export function SettingsRow({
testID,
}: AppSettingsRowProps) {
const compact = useIsCompactFormFactor();
const layout = useSettingsRowLayout();
const rowStyle = useMemo(() => [layout.row, compact && styles.compactRow], [compact, layout]);
const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]);
const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
return (
<View style={rowStyle} testID={testID}>
<View style={styles.label}>
{labelAccessory ? (
<View style={styles.labelRow}>
<Text style={accessoryTitleStyle}>{label}</Text>
<Text style={[settingsStyles.rowTitle, styles.accessoryLabel]}>{label}</Text>
{labelAccessory}
</View>
) : (
<Text style={layout.title}>{label}</Text>
<Text style={settingsStyles.rowTitle}>{label}</Text>
)}
{typeof hint === "string" ? <Text style={layout.hint}>{hint}</Text> : hint}
{typeof hint === "string" ? <Text style={settingsStyles.rowHint}>{hint}</Text> : hint}
{error ? (
<Text accessibilityRole="alert" style={settingsStyles.rowError}>
{error}
</Text>
) : null}
</View>
{children ? (
<View style={styles.control}>
<ControlSizeProvider size={layout.controlSize}>{children}</ControlSizeProvider>
</View>
) : null}
{children ? <View style={styles.control}>{children}</View> : null}
</View>
);
}
@@ -184,7 +175,7 @@ export function SettingsInput({
editable={!disabled}
secureTextEntry={secureTextEntry}
accessibilityLabel={row.label}
size={compact ? "md" : undefined}
size={compact ? "md" : "sm"}
style={styles.input}
/>
</SettingsRow>
@@ -194,7 +185,7 @@ export function SettingsInput({
export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) {
return (
<SettingsRow {...row}>
<Button variant="outline" onPress={onPress} disabled={disabled}>
<Button variant="outline" size="sm" onPress={onPress} disabled={disabled}>
{actionLabel}
</Button>
</SettingsRow>
@@ -1,77 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Button } from "@/components/ui/button";
import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function mountRows() {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root?.render(
<SettingsCard>
<SettingsRow label="Card row" testID="card-row">
<Button variant="outline" testID="card-button">
Download
</Button>
</SettingsRow>
<SettingsCollapsibleRow label="Group" defaultExpanded testID="group">
<SettingsRow label="Nested with a button" testID="nested-with-button">
<Button variant="outline" testID="nested-button">
Download
</Button>
</SettingsRow>
<SettingsRow label="Nested without a button" testID="nested-without-button" />
</SettingsCollapsibleRow>
</SettingsCard>,
),
);
}
function element(testID: string): HTMLElement {
const found = document.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`);
return found;
}
function heightOf(testID: string): number {
return element(testID).getBoundingClientRect().height;
}
function titleFontSize(rowTestID: string, label: string): number {
const title = [...element(rowTestID).querySelectorAll("div")].find(
(node) => node.textContent === label && node.children.length === 0,
);
if (!title) throw new Error(`No title ${label}`);
return Number.parseFloat(getComputedStyle(title).fontSize);
}
describe("settings row density", () => {
it("lines up nested rows with and without a button, tighter than card rows", () => {
mountRows();
expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button"));
expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row"));
expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button"));
});
it("sizes nested text and controls one step below card rows", () => {
mountRows();
expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button"));
expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan(
titleFontSize("card-row", "Card row"),
);
});
});
@@ -1,57 +0,0 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import type { ContainedControlSize } from "@/components/ui/control-size";
import { settingsStyles } from "@/styles/settings";
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
// of the card: tighter padding, smaller text, smaller controls. The container decides; a row
// never knows on its own that it is nested.
const NestedRowsContext = createContext(false);
export function NestedSettingsRows({ children }: { children: ReactNode }) {
return <NestedRowsContext.Provider value>{children}</NestedRowsContext.Provider>;
}
export interface SettingsRowLayout {
row: StyleProp<ViewStyle>;
title: StyleProp<TextStyle>;
hint: StyleProp<TextStyle>;
/** The size of the controls in the row's trailing slot. */
controlSize: ContainedControlSize;
}
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
export function useSettingsRowLayout(): SettingsRowLayout {
const nested = useContext(NestedRowsContext);
return useMemo(
() =>
nested
? {
row: [settingsStyles.row, styles.nestedRow],
title: [settingsStyles.rowTitle, styles.nestedText],
hint: [settingsStyles.rowHint, styles.nestedText],
controlSize: "xs",
}
: {
row: settingsStyles.row,
title: settingsStyles.rowTitle,
hint: settingsStyles.rowHint,
controlSize: "sm",
},
[nested],
);
}
const styles = StyleSheet.create((theme) => ({
// Every nested row is as tall as one holding an `xs` control, so rows with and without a
// trailing button line up.
nestedRow: {
paddingVertical: theme.spacing[2],
minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2,
},
nestedText: {
fontSize: theme.fontSize.sm,
},
}));
@@ -41,7 +41,11 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
useHosts,
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import {
useSidebarWorkspacePinController,
@@ -1596,14 +1600,18 @@ function ProjectBlock({
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
project,
collapsed,
supportsMultiplicityByServerId,
preferredServerId,
}),
[collapsed, project, supportsMultiplicityByServerId],
[collapsed, preferredServerId, project, supportsMultiplicityByServerId],
);
// Collapsed rows hide their workspace rows, so the project row carries the most urgent
@@ -1848,6 +1856,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
areProjectBlockSelectionsEqual(previous, next)
);
}
@@ -0,0 +1,85 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAllByRole } from "@testing-library/dom";
import { i18n as testI18n } from "@/i18n/i18next";
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
import { SidebarProjectEmptyState } from "./empty-states";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
afterEach(async () => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
await testI18n.changeLanguage("en");
});
async function mountEmptyState(language: string, width: number): Promise<HTMLElement> {
await testI18n.changeLanguage(language);
const container = document.createElement("div");
container.style.width = `${width}px`;
// Match the workspace list's horizontal inset around the card.
container.style.paddingInline = "6px";
container.style.boxSizing = "border-box";
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<SidebarProjectEmptyState />));
mounted.push({ root, container });
const card = container.querySelector('[data-testid="sidebar-project-empty-state"]');
if (!(card instanceof HTMLElement)) {
throw new Error("SidebarProjectEmptyState did not render its card");
}
return card;
}
function expectActionsInsideCard(card: HTMLElement) {
const cardRect = card.getBoundingClientRect();
const buttons = getAllByRole(card, "button");
expect(buttons).toHaveLength(2);
for (const button of buttons) {
const rect = button.getBoundingClientRect();
expect(rect.left).toBeGreaterThanOrEqual(cardRect.left);
expect(rect.right).toBeLessThanOrEqual(cardRect.right);
// Containing the button must also keep the whole label readable, without clipping.
const label = document.createRange();
label.selectNodeContents(button);
for (const fragment of Array.from(label.getClientRects())) {
expect(fragment.left).toBeGreaterThanOrEqual(rect.left);
expect(fragment.right).toBeLessThanOrEqual(rect.right);
expect(fragment.top).toBeGreaterThanOrEqual(rect.top);
expect(fragment.bottom).toBeLessThanOrEqual(rect.bottom);
}
}
}
describe("SidebarProjectEmptyState", () => {
const cases = ["en", "ru", "fr", "ja", "es", "pt-BR", "ar", "ko", "zh-CN"].flatMap((language) =>
[MIN_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH].map((width) => ({ language, width })),
);
it.each(cases)(
"keeps every action inside the card ($language, $width px sidebar)",
async ({ language, width }) => {
expectActionsInsideCard(await mountEmptyState(language, width));
},
);
it("keeps English actions on one row when they fit", async () => {
const card = await mountEmptyState("en", DEFAULT_SIDEBAR_WIDTH);
const [addProject, importSession] = getAllByRole(card, "button");
expect(addProject.getBoundingClientRect().top).toBe(importSession.getBoundingClientRect().top);
});
});
@@ -54,7 +54,13 @@ export function SidebarFilterEmptyState() {
title={t("sidebar.filterEmpty.title")}
description={t("sidebar.filterEmpty.description")}
>
<Button variant="secondary" size="xs" onPress={clearFilters}>
<Button
variant="secondary"
size="xs"
style={styles.action}
textStyle={styles.actionText}
onPress={clearFilters}
>
{t("sidebar.filterEmpty.clear")}
</Button>
</SidebarEmptyStateCard>
@@ -76,10 +82,24 @@ export function SidebarProjectEmptyState({
title={t("sidebar.project.empty.title")}
description={t("sidebar.project.empty.description")}
>
<Button variant="secondary" size="xs" leftIcon={Plus} onPress={onAddProject}>
<Button
variant="secondary"
size="xs"
leftIcon={Plus}
style={styles.action}
textStyle={styles.actionText}
onPress={onAddProject}
>
{t("sidebar.actions.addProject")}
</Button>
<Button variant="outline" size="xs" leftIcon={Import} onPress={onImportSession}>
<Button
variant="outline"
size="xs"
leftIcon={Import}
style={styles.action}
textStyle={styles.actionText}
onPress={onImportSession}
>
{t("importSession.title")}
</Button>
</SidebarEmptyStateCard>
@@ -99,9 +119,16 @@ const styles = StyleSheet.create((theme) => ({
},
actions: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
action: {
maxWidth: "100%",
},
actionText: {
flexShrink: 1,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
@@ -1,5 +1,6 @@
import {
Fragment,
memo,
useCallback,
useEffect,
useMemo,
@@ -88,7 +89,8 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { RenderProfile } from "@/utils/render-profiler";
import { recordRenderProfileReasons, RenderProfile } from "@/utils/render-profiler";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { isNative } from "@/constants/platform";
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
@@ -435,7 +437,7 @@ export function SplitContainer({
: inheritedWindowChromeCorners;
const mainColumnStyle = styles.mainColumn;
const explorerSidebarDockStyle = useMemo(
() => [styles.explorerSidebarDock, { width: explorerSidebarWidth }],
() => [styles.explorerSidebarDock, inlineUnistylesStyle({ width: explorerSidebarWidth })],
[explorerSidebarWidth],
);
const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => {
@@ -1121,7 +1123,7 @@ function SplitNodeView({
);
}
function SplitPaneView({
const SplitPaneView = memo(function SplitPaneView({
pane,
uiTabs,
isFocused,
@@ -1158,6 +1160,7 @@ function SplitPaneView({
focusModeEnabled,
onExitFocusMode,
}: SplitPaneViewProps) {
recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]);
const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane);
const paneState = useMemo(
@@ -1312,7 +1315,7 @@ function SplitPaneView({
</View>
</RenderProfile>
);
}
});
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
const next = new Map<string, SplitPane>();
+1 -4
View File
@@ -1,5 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useContainedControlSize } from "@/components/ui/control-size";
import {
default as React,
useCallback,
@@ -163,7 +162,7 @@ const styles = StyleSheet.create((theme) => {
export function Button({
children,
variant = "secondary",
size: sizeProp,
size = "md",
leftIcon,
trailing,
style,
@@ -186,8 +185,6 @@ export function Button({
}
>) {
const [hovered, setHovered] = useState(false);
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const isDisabled = disabled || loading;
let variantStyle: ViewStyle;
@@ -81,8 +81,6 @@ describe("control geometry", () => {
it("keeps field text sizing tied to control size", () => {
const geometry = createControlGeometry(theme);
expect(geometry.fieldTextXs.fontSize).toBe(12);
expect(geometry.fieldTextXs.lineHeight).toBe(17);
expect(geometry.fieldTextSm.fontSize).toBe(14);
expect(geometry.fieldTextSm.lineHeight).toBe(20);
expect(geometry.fieldTextMd.fontSize).toBe(14);
@@ -97,13 +95,6 @@ describe("control geometry", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlXs.minHeight).toBe(28);
expect(
geometry.fieldTextXs.lineHeight +
geometry.fieldControlXs.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlXs.minHeight);
expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
expect(
@@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "xs" | "sm" | "md";
export type FieldControlSize = "sm" | "md";
export type SegmentedControlSize = "xs" | "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active";
@@ -188,19 +188,8 @@ export function resolveControlInteractionStyles(
export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1];
const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlXs = {
minHeight: CONTROL_HEIGHTS.tight,
paddingHorizontal: theme.spacing[2],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.tight,
fieldTextXsLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3],
@@ -221,10 +210,6 @@ export function createControlGeometry(theme: Theme) {
),
borderRadius: theme.borderRadius.lg,
};
const fieldTextXs = {
fontSize: theme.fontSize.sm,
lineHeight: fieldTextXsLineHeight,
};
const fieldTextSm = {
fontSize: theme.fontSize.base,
lineHeight: fieldTextSmLineHeight,
@@ -267,10 +252,6 @@ export function createControlGeometry(theme: Theme) {
fontSize: theme.fontSize.sm,
},
alert,
formTextInputXs: {
...fieldControlXs,
...fieldTextXs,
},
formTextInputSm: {
...fieldControlSm,
...fieldTextSm,
@@ -283,10 +264,8 @@ export function createControlGeometry(theme: Theme) {
...fieldControlMd,
...fieldTextMd,
},
fieldControlXs,
fieldControlSm,
fieldControlMd,
fieldTextXs,
fieldTextSm,
fieldTextMd,
controlRest: {
@@ -1,23 +0,0 @@
import { createContext, useContext, type ReactNode } from "react";
/** The control sizes a container can ask of the controls placed in it. */
export type ContainedControlSize = "xs" | "sm";
// A row decides how big its controls are, so a button or field placed in it needs no size of
// its own. A size passed to the control still wins.
const ControlSizeContext = createContext<ContainedControlSize | null>(null);
export function ControlSizeProvider({
size,
children,
}: {
size: ContainedControlSize;
children: ReactNode;
}) {
return <ControlSizeContext.Provider value={size}>{children}</ControlSizeContext.Provider>;
}
/** The size the surrounding container asks for, or null outside one. */
export function useContainedControlSize(): ContainedControlSize | null {
return useContext(ControlSizeContext);
}
@@ -9,7 +9,6 @@ import {
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
import { useContainedControlSize } from "@/components/ui/control-size";
const ThemedChevronDown = withUnistyles(ChevronDown);
@@ -33,12 +32,10 @@ interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"
export function DropdownTrigger({
children,
leading,
size: sizeProp,
size = "sm",
disabled,
...props
}: DropdownTriggerProps): ReactElement {
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "sm";
let sizeStyle: ViewStyle;
if (size === "xs") {
sizeStyle = styles.xs;
+3 -21
View File
@@ -23,16 +23,6 @@ import {
resolveControlInteractionStyles,
type FieldControlSize,
} from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record<
FieldControlSize,
string
>;
const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record<
FieldControlSize,
string
>;
interface FieldProps {
label: string;
@@ -169,13 +159,11 @@ function assignTextInputRef(
}
export const FormTextInput = forwardRef<EditingTextInputHandle, FormTextInputProps>(
function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) {
function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) {
const [focused, setFocused] = useState(false);
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const isDisabled = editable === false;
const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]];
const inputSizeStyle = formInputStyles[FIELD_TEXT[size]];
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
const setInputRef = useCallback(
(node: EditingTextInputHandle | null) => {
@@ -274,9 +262,6 @@ const formInputStyles = StyleSheet.create((theme) => {
chrome: {
backgroundColor: theme.colors.surface2,
},
chromeXs: {
...geometry.fieldControlXs,
},
chromeSm: {
...geometry.fieldControlSm,
},
@@ -304,9 +289,6 @@ const formInputStyles = StyleSheet.create((theme) => {
outlineColor: "transparent",
outlineWidth: 0,
},
inputXs: {
...geometry.fieldTextXs,
},
inputSm: {
...geometry.fieldTextSm,
},
@@ -63,7 +63,9 @@ describe("findHighlightRanges", () => {
);
});
it("uses the search matcher for typo and scattered-character matches", () => {
expect(marked("billing", findHighlightRanges("bulling", "billing"))).toBe("[billing]");
expect(
marked("billing", findHighlightRanges("bulling", "billing", { typoTolerant: true })),
).toBe("[billing]");
expect(marked("paseo-babysit", findHighlightRanges("pasbab", "paseo-babysit"))).toBe(
"[pas]eo-[bab]ysit",
);
@@ -79,6 +81,9 @@ describe("findHighlightRanges", () => {
"check [conf]i[g]uration",
);
});
it("marks typos only when the list was matched typo-tolerantly", () => {
expect(findHighlightRanges("bulling", "billing")).toEqual([]);
});
it("merges overlapping tokens and ignores empty or unmatched queries", () => {
expect(marked("billing", findHighlightRanges("bill billing", "billing"))).toBe("[billing]");
expect(findHighlightRanges("", "billing")).toEqual([]);
@@ -4,6 +4,7 @@ import {
scoreMatch,
tokenizeQuery,
type MatchRange,
type TextFieldsOptions,
} from "@getpaseo/protocol/search/text-match";
export interface HighlightSegment {
@@ -40,11 +41,22 @@ export function toHighlightSegments(
return segments;
}
/** Best-effort presentation of each query token in this field, independently of other fields. */
export function findHighlightRanges(query: string, text: string): MatchRange[] {
/**
* Best-effort presentation of each query token in this field, independently of other fields.
* Pass the same options the list was matched with, so a mark never claims a match the list did
* not make.
*/
export function findHighlightRanges(
query: string,
text: string,
options: TextFieldsOptions = {},
): MatchRange[] {
const ranges = tokenizeQuery(query)
.flatMap((token) => {
const score = scoreMatch(token, text, { fuzzy: fuzzyPolicyForToken(token) });
const score = scoreMatch(token, text, {
fuzzy: options.typoTolerant ? fuzzyPolicyForToken(token) : null,
subsequence: options.subsequence,
});
return score ? matchRanges(token, text, score) : [];
})
.sort((left, right) => left.start - right.start);
@@ -24,7 +24,7 @@ export function validateDraftSubmission(input: {
};
autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null;
workspaceDirectory: string | null;
hasClient: boolean;
isConnected: boolean;
}): string | null {
const {
text,
@@ -32,7 +32,7 @@ export function validateDraftSubmission(input: {
composerState,
autoSubmitConfig,
workspaceDirectory,
hasClient,
isConnected,
} = input;
const readiness = resolveSubmissionReadiness({
text,
@@ -46,7 +46,7 @@ export function validateDraftSubmission(input: {
},
autoSubmitConfig,
workspaceDirectory,
hasClient,
isConnected,
});
return readiness.ok ? null : (readiness.reason ?? null);
}
@@ -17,12 +17,26 @@ function validate(overrides = {}) {
composerState: baseComposerState,
autoSubmitConfig: null,
workspaceDirectory: "/tmp/project",
hasClient: true,
isConnected: true,
...overrides,
});
}
describe("workspace draft agent model validation", () => {
test("reports an offline host before an absent catalog or pending model defaults", () => {
expect(
validate({
isConnected: false,
composerState: {
...baseComposerState,
providerDefinitions: [],
selectedProvider: null,
isModelLoading: true,
},
}),
).toBe("Host is not connected");
});
test("allows a ready provider with no models to submit without a selected model", () => {
expect(validate({})).toBeNull();
});
@@ -16,7 +16,7 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -305,6 +305,7 @@ export function WorkspaceDraftAgentTab({
}: WorkspaceDraftAgentTabProps) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
workspaceDirectory: w.workspaceDirectory,
id: w.id,
@@ -445,7 +446,7 @@ export function WorkspaceDraftAgentTab({
composerState,
autoSubmitConfig,
workspaceDirectory: draftWorkingDirectory,
hasClient: Boolean(client),
isConnected,
});
},
onBeforeSubmit: async () => {
@@ -36,6 +36,7 @@ import {
type FormState,
type ProviderModelsByProvider,
} from "@/provider-selection/resolve-agent-form";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { MaterializedAgentProfile } from "@/agent-profiles";
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
@@ -186,6 +187,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, { cwd: workingDir });
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(
() => buildProviderDefinitions(snapshotEntries),
@@ -220,8 +223,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
[snapshotEntries],
);
const snapshotModelSelectorProviders = useMemo(
() => buildSelectableProviderSelectorProviders(snapshotEntries),
[snapshotEntries],
() => buildSelectableProviderSelectorProviders(snapshotEntries, isConnected),
[snapshotEntries, isConnected],
);
const snapshotSelectedEntry = useMemo(
() =>
@@ -247,7 +250,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const isModelSelectionLoading =
resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading;
isConnected &&
(resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading);
const isAllModelsLoading = isModelSelectionLoading;
useEffect(() => {
-70
View File
@@ -2106,7 +2106,6 @@ export const ar: TranslationResources = {
connections: "اتصالات",
agents: "Agents",
metadata: "البيانات الوصفية",
voice: "الصوت",
workspaces: "Workspaces",
providers: "مقدمي الخدمات",
usage: "الاستخدام",
@@ -2129,75 +2128,6 @@ export const ar: TranslationResources = {
docs: "الوثائق",
saveError: "تعذر تحديث إنشاء البيانات الوصفية",
},
voice: {
offline: "اتصل بهذا المضيف لضبط الصوت.",
updateHost: "حدّث المضيف لضبط الصوت.",
saveError: "تعذّر تحديث إعدادات الصوت",
provider: "المزوّد",
providers: {
local: "محلي",
openai: "OpenAI",
},
sttModel: "نموذج تحويل الكلام إلى نص",
ttsModel: "نموذج الصوت",
voice: "الصوت",
notDownloadedOption: "{{label}} (غير منزّل)",
save: "حفظ",
retry: "إعادة المحاولة",
dictation: {
title: "الإملاء",
enabled: "الإملاء",
enabledHint: "تحدّث في مربع الرسالة بدلًا من الكتابة",
},
voiceMode: {
title: "وضع الصوت",
info: "يحوّل وضع الصوت كلامك إلى نص ويقرأ ردود الوكيل بصوت عالٍ.",
enabled: "وضع الصوت",
enabledHint: "تحدّث مع وكلائك دون استخدام يديك",
sttProvider: "تحويل الكلام إلى نص",
ttsProvider: "تحويل النص إلى كلام",
},
openai: {
apiKey: "مفتاح API",
apiKeyHint: "يُحفظ على المضيف ولا يُعرض مرة أخرى.",
keyPlaceholder: "sk-...",
replaceKeyPlaceholder: "مفتاح جديد",
removeKey: "إزالة",
removeKeyTitle: "إزالة مفتاح OpenAI؟",
removeKeyMessage: "ستتوقف الميزات المضبوطة على OpenAI عن العمل حتى تضيف مفتاحًا.",
baseUrl: "عنوان URL الأساسي",
baseUrlHint: "للخوادم المتوافقة مع OpenAI. اتركه فارغًا لاستخدام OpenAI.",
missingKeyWarning: "تم اختيار OpenAI لكن لم يُضبط مفتاح API.",
},
models: {
loading: "جارٍ تحميل النماذج...",
loadError: "تعذّر تحميل النماذج المحلية",
downloaded: "منزّل",
inUse: "قيد الاستخدام",
recommended: "موصى به",
download: "تنزيل",
downloading: "جارٍ التنزيل...",
downloadingPercent: "جارٍ التنزيل {{percent}}%",
extracting: "جارٍ الاستخراج...",
delete: "حذف",
deleteAction: "حذف {{label}}",
deleteTitle: "حذف {{label}}؟",
deleteMessage: "سيؤدي هذا إلى تحرير {{size}}. يمكنك تنزيله مرة أخرى لاحقًا.",
languages: "لغات {{label}}",
moreLanguages: "{{languages}} و{{count}} أخرى",
},
providerSettings: {
title: "المزوّدون",
info: "النماذج وبيانات الاعتماد لكل مزوّد. تعمل النماذج المحلية على هذا المضيف وتعرض حجمها ولغاتها قبل التنزيل.",
localStt: "تحويل الكلام إلى نص محليًا",
localTts: "تحويل النص إلى كلام محليًا",
openai: "OpenAI",
downloaded_one: "تم تنزيل {{count}} نموذج",
downloaded_other: "تم تنزيل {{count}} نماذج",
configured: "تم الإعداد",
notConfigured: "غير مُعد",
},
},
general: {
title: "عام",
sending: "الإرسال",
-70
View File
@@ -2169,7 +2169,6 @@ export const en = {
connections: "Connections",
agents: "Agents",
metadata: "Metadata",
voice: "Voice",
workspaces: "Workspaces",
providers: "Providers",
usage: "Usage",
@@ -2254,75 +2253,6 @@ export const en = {
docs: "Docs",
saveError: "Unable to update metadata generation",
},
voice: {
offline: "Connect to this host to configure voice.",
updateHost: "Update the host to configure voice.",
saveError: "Unable to update voice settings",
provider: "Provider",
providers: {
local: "Local",
openai: "OpenAI",
},
sttModel: "Speech-to-text model",
ttsModel: "Voice model",
voice: "Voice",
notDownloadedOption: "{{label}} (not downloaded)",
save: "Save",
retry: "Retry",
dictation: {
title: "Dictation",
enabled: "Dictation",
enabledHint: "Speak into the message box instead of typing",
},
voiceMode: {
title: "Voice mode",
info: "Voice mode transcribes what you say and reads agent replies aloud.",
enabled: "Voice mode",
enabledHint: "Talk with your agents hands-free",
sttProvider: "Speech-to-text",
ttsProvider: "Text-to-speech",
},
openai: {
apiKey: "API key",
apiKeyHint: "Saved on the host and never shown again.",
keyPlaceholder: "sk-...",
replaceKeyPlaceholder: "New key",
removeKey: "Remove",
removeKeyTitle: "Remove the OpenAI API key?",
removeKeyMessage: "Features set to OpenAI stop working until you add a key.",
baseUrl: "Base URL",
baseUrlHint: "For OpenAI-compatible servers. Leave empty to use OpenAI.",
missingKeyWarning: "OpenAI is selected but no API key is set.",
},
models: {
loading: "Loading models...",
loadError: "Couldn't load local models",
downloaded: "Downloaded",
inUse: "In use",
recommended: "Recommended",
download: "Download",
downloading: "Downloading...",
downloadingPercent: "Downloading {{percent}}%",
extracting: "Extracting...",
delete: "Delete",
deleteAction: "Delete {{label}}",
deleteTitle: "Delete {{label}}?",
deleteMessage: "This frees {{size}}. You can download it again later.",
languages: "{{label}} languages",
moreLanguages: "{{languages}} and {{count}} more",
},
providerSettings: {
title: "Providers",
info: "Models and credentials for each provider. Local models run on this host and list their size and languages before you download.",
localStt: "Local speech-to-text",
localTts: "Local text-to-speech",
openai: "OpenAI",
downloaded_one: "{{count}} model downloaded",
downloaded_other: "{{count}} models downloaded",
configured: "Configured",
notConfigured: "Not configured",
},
},
general: {
title: "General",
sending: "Sending",
-72
View File
@@ -2154,7 +2154,6 @@ export const es: TranslationResources = {
connections: "Conexiones",
agents: "Agents",
metadata: "Metadatos",
voice: "Voz",
workspaces: "Workspaces",
providers: "Proveedores",
usage: "Uso",
@@ -2177,77 +2176,6 @@ export const es: TranslationResources = {
docs: "Documentación",
saveError: "No se pudo actualizar la generación de metadatos",
},
voice: {
offline: "Conéctate a este host para configurar la voz.",
updateHost: "Actualiza el host para configurar la voz.",
saveError: "No se pudo actualizar la configuración de voz",
provider: "Proveedor",
providers: {
local: "Local",
openai: "OpenAI",
},
sttModel: "Modelo de voz a texto",
ttsModel: "Modelo de voz",
voice: "Voz",
notDownloadedOption: "{{label}} (sin descargar)",
save: "Guardar",
retry: "Reintentar",
dictation: {
title: "Dictado",
enabled: "Dictado",
enabledHint: "Habla en el cuadro de mensaje en lugar de escribir",
},
voiceMode: {
title: "Modo de voz",
info: "El modo de voz transcribe lo que dices y lee en voz alta las respuestas del agente.",
enabled: "Modo de voz",
enabledHint: "Habla con tus agentes sin usar las manos",
sttProvider: "Voz a texto",
ttsProvider: "Texto a voz",
},
openai: {
apiKey: "Clave de API",
apiKeyHint: "Se guarda en el host y no se vuelve a mostrar.",
keyPlaceholder: "sk-...",
replaceKeyPlaceholder: "Nueva clave",
removeKey: "Quitar",
removeKeyTitle: "¿Quitar la clave de API de OpenAI?",
removeKeyMessage:
"Las funciones configuradas con OpenAI dejarán de funcionar hasta que añadas una clave.",
baseUrl: "URL base",
baseUrlHint: "Para servidores compatibles con OpenAI. Déjala vacía para usar OpenAI.",
missingKeyWarning:
"OpenAI está seleccionado, pero no hay ninguna clave de API configurada.",
},
models: {
loading: "Cargando modelos...",
loadError: "No se pudieron cargar los modelos locales",
downloaded: "Descargado",
inUse: "En uso",
recommended: "Recomendado",
download: "Descargar",
downloading: "Descargando...",
downloadingPercent: "Descargando {{percent}} %",
extracting: "Extrayendo...",
delete: "Eliminar",
deleteAction: "Eliminar {{label}}",
deleteTitle: "¿Eliminar {{label}}?",
deleteMessage: "Esto libera {{size}}. Puedes volver a descargarlo más tarde.",
languages: "Idiomas de {{label}}",
moreLanguages: "{{languages}} y {{count}} más",
},
providerSettings: {
title: "Proveedores",
info: "Modelos y credenciales de cada proveedor. Los modelos locales se ejecutan en este host e indican su tamaño e idiomas antes de descargarlos.",
localStt: "Voz a texto local",
localTts: "Texto a voz local",
openai: "OpenAI",
downloaded_one: "{{count}} modelo descargado",
downloaded_other: "{{count}} modelos descargados",
configured: "Configurado",
notConfigured: "Sin configurar",
},
},
general: {
title: "General",
sending: "Envío",
-71
View File
@@ -2153,7 +2153,6 @@ export const fr: TranslationResources = {
connections: "Connexions",
agents: "Agents",
metadata: "Métadonnées",
voice: "Voix",
workspaces: "Espaces de travail",
providers: "Fournisseurs",
usage: "Utilisation",
@@ -2176,76 +2175,6 @@ export const fr: TranslationResources = {
docs: "Documentation",
saveError: "Impossible de mettre à jour la génération de métadonnées",
},
voice: {
offline: "Connectez-vous à cet hôte pour configurer la voix.",
updateHost: "Mettez à jour l'hôte pour configurer la voix.",
saveError: "Impossible de mettre à jour les réglages vocaux",
provider: "Fournisseur",
providers: {
local: "Local",
openai: "OpenAI",
},
sttModel: "Modèle de transcription",
ttsModel: "Modèle de voix",
voice: "Voix",
notDownloadedOption: "{{label}} (non téléchargé)",
save: "Enregistrer",
retry: "Réessayer",
dictation: {
title: "Dictée",
enabled: "Dictée",
enabledHint: "Parlez dans la zone de message au lieu de taper",
},
voiceMode: {
title: "Mode vocal",
info: "Le mode vocal transcrit ce que vous dites et lit à voix haute les réponses de l'agent.",
enabled: "Mode vocal",
enabledHint: "Parlez à vos agents sans les mains",
sttProvider: "Transcription",
ttsProvider: "Synthèse vocale",
},
openai: {
apiKey: "Clé d'API",
apiKeyHint: "Enregistrée sur l'hôte, jamais réaffichée.",
keyPlaceholder: "sk-...",
replaceKeyPlaceholder: "Nouvelle clé",
removeKey: "Supprimer",
removeKeyTitle: "Supprimer la clé d'API OpenAI ?",
removeKeyMessage:
"Les fonctions réglées sur OpenAI cesseront de fonctionner jusqu'à l'ajout d'une clé.",
baseUrl: "URL de base",
baseUrlHint: "Pour les serveurs compatibles OpenAI. Laissez vide pour utiliser OpenAI.",
missingKeyWarning: "OpenAI est sélectionné, mais aucune clé d'API n'est définie.",
},
models: {
loading: "Chargement des modèles...",
loadError: "Impossible de charger les modèles locaux",
downloaded: "Téléchargé",
inUse: "Utilisé",
recommended: "Recommandé",
download: "Télécharger",
downloading: "Téléchargement...",
downloadingPercent: "Téléchargement {{percent}} %",
extracting: "Extraction...",
delete: "Supprimer",
deleteAction: "Supprimer {{label}}",
deleteTitle: "Supprimer {{label}} ?",
deleteMessage: "Cela libère {{size}}. Vous pourrez le retélécharger plus tard.",
languages: "Langues de {{label}}",
moreLanguages: "{{languages}} et {{count}} autres",
},
providerSettings: {
title: "Fournisseurs",
info: "Modèles et identifiants de chaque fournisseur. Les modèles locaux s'exécutent sur cet hôte et indiquent leur taille et leurs langues avant le téléchargement.",
localStt: "Reconnaissance vocale locale",
localTts: "Synthèse vocale locale",
openai: "OpenAI",
downloaded_one: "{{count}} modèle téléchargé",
downloaded_other: "{{count}} modèles téléchargés",
configured: "Configuré",
notConfigured: "Non configuré",
},
},
general: {
title: "Général",
sending: "Envoi",
-70
View File
@@ -2127,7 +2127,6 @@ export const ja: TranslationResources = {
connections: "接続",
agents: "エージェント",
metadata: "メタデータ",
voice: "音声",
workspaces: "ワークスペース",
providers: "プロバイダー",
usage: "使用状況",
@@ -2150,75 +2149,6 @@ export const ja: TranslationResources = {
docs: "ドキュメント",
saveError: "メタデータ生成を更新できません",
},
voice: {
offline: "音声を設定するには、このホストに接続してください。",
updateHost: "音声を設定するには、ホストを更新してください。",
saveError: "音声設定を更新できませんでした",
provider: "プロバイダー",
providers: {
local: "ローカル",
openai: "OpenAI",
},
sttModel: "音声認識モデル",
ttsModel: "音声モデル",
voice: "ボイス",
notDownloadedOption: "{{label}}(未ダウンロード)",
save: "保存",
retry: "再試行",
dictation: {
title: "音声入力",
enabled: "音声入力",
enabledHint: "入力する代わりにメッセージ欄に話しかけます",
},
voiceMode: {
title: "ボイスモード",
info: "ボイスモードは話した内容を文字起こしし、エージェントの返答を読み上げます。",
enabled: "ボイスモード",
enabledHint: "ハンズフリーでエージェントと会話します",
sttProvider: "音声認識",
ttsProvider: "音声合成",
},
openai: {
apiKey: "API キー",
apiKeyHint: "ホストに保存され、再表示されません。",
keyPlaceholder: "sk-...",
replaceKeyPlaceholder: "新しいキー",
removeKey: "削除",
removeKeyTitle: "OpenAI API キーを削除しますか?",
removeKeyMessage: "キーを追加するまで、OpenAI に設定された機能は動作しません。",
baseUrl: "ベース URL",
baseUrlHint: "OpenAI 互換サーバー用です。OpenAI を使う場合は空欄のままにします。",
missingKeyWarning: "OpenAI が選択されていますが、API キーが設定されていません。",
},
models: {
loading: "モデルを読み込み中...",
loadError: "ローカルモデルを読み込めませんでした",
downloaded: "ダウンロード済み",
inUse: "使用中",
recommended: "おすすめ",
download: "ダウンロード",
downloading: "ダウンロード中...",
downloadingPercent: "ダウンロード中 {{percent}}%",
extracting: "展開中...",
delete: "削除",
deleteAction: "{{label}} を削除",
deleteTitle: "{{label}} を削除しますか?",
deleteMessage: "{{size}} が解放されます。後で再ダウンロードできます。",
languages: "{{label}} の対応言語",
moreLanguages: "{{languages}} ほか {{count}} 言語",
},
providerSettings: {
title: "プロバイダー",
info: "各プロバイダーのモデルと認証情報です。ローカルモデルはこのホストで動作し、ダウンロード前にサイズと対応言語を確認できます。",
localStt: "ローカル音声認識",
localTts: "ローカル音声合成",
openai: "OpenAI",
downloaded_one: "{{count}} 個のモデルをダウンロード済み",
downloaded_other: "{{count}} 個のモデルをダウンロード済み",
configured: "設定済み",
notConfigured: "未設定",
},
},
general: {
title: "一般",
sending: "送信",
-70
View File
@@ -2116,7 +2116,6 @@ export const ko: TranslationResources = {
connections: "연결",
agents: "에이전트",
metadata: "메타데이터",
voice: "음성",
workspaces: "워크스페이스",
providers: "프로바이더",
usage: "사용량",
@@ -2139,75 +2138,6 @@ export const ko: TranslationResources = {
docs: "문서",
saveError: "메타데이터 생성을 업데이트할 수 없습니다",
},
voice: {
offline: "음성을 설정하려면 이 호스트에 연결하세요.",
updateHost: "음성을 설정하려면 호스트를 업데이트하세요.",
saveError: "음성 설정을 업데이트할 수 없습니다",
provider: "제공자",
providers: {
local: "로컬",
openai: "OpenAI",
},
sttModel: "음성 인식 모델",
ttsModel: "음성 모델",
voice: "목소리",
notDownloadedOption: "{{label}} (다운로드 안 됨)",
save: "저장",
retry: "다시 시도",
dictation: {
title: "받아쓰기",
enabled: "받아쓰기",
enabledHint: "입력하는 대신 메시지 상자에 말하세요",
},
voiceMode: {
title: "음성 모드",
info: "음성 모드는 말한 내용을 텍스트로 옮기고 에이전트의 답변을 소리 내어 읽어 줍니다.",
enabled: "음성 모드",
enabledHint: "손을 쓰지 않고 에이전트와 대화하세요",
sttProvider: "음성 인식",
ttsProvider: "음성 합성",
},
openai: {
apiKey: "API 키",
apiKeyHint: "호스트에 저장되며 다시 표시되지 않습니다.",
keyPlaceholder: "sk-...",
replaceKeyPlaceholder: "새 키",
removeKey: "제거",
removeKeyTitle: "OpenAI API 키를 제거할까요?",
removeKeyMessage: "키를 추가할 때까지 OpenAI로 설정된 기능이 작동하지 않습니다.",
baseUrl: "기본 URL",
baseUrlHint: "OpenAI 호환 서버용입니다. OpenAI를 사용하려면 비워 두세요.",
missingKeyWarning: "OpenAI가 선택되었지만 API 키가 설정되지 않았습니다.",
},
models: {
loading: "모델을 불러오는 중...",
loadError: "로컬 모델을 불러올 수 없습니다",
downloaded: "다운로드됨",
inUse: "사용 중",
recommended: "추천",
download: "다운로드",
downloading: "다운로드 중...",
downloadingPercent: "다운로드 중 {{percent}}%",
extracting: "압축 해제 중...",
delete: "삭제",
deleteAction: "{{label}} 삭제",
deleteTitle: "{{label}}을(를) 삭제할까요?",
deleteMessage: "{{size}}가 확보됩니다. 나중에 다시 다운로드할 수 있습니다.",
languages: "{{label}} 지원 언어",
moreLanguages: "{{languages}} 외 {{count}}개",
},
providerSettings: {
title: "제공자",
info: "제공자별 모델과 자격 증명입니다. 로컬 모델은 이 호스트에서 실행되며 다운로드 전에 크기와 지원 언어를 보여줍니다.",
localStt: "로컬 음성 인식",
localTts: "로컬 음성 합성",
openai: "OpenAI",
downloaded_one: "모델 {{count}}개 다운로드됨",
downloaded_other: "모델 {{count}}개 다운로드됨",
configured: "설정됨",
notConfigured: "설정 안 됨",
},
},
general: {
title: "일반",
sending: "전송",
-71
View File
@@ -2139,7 +2139,6 @@ export const ptBR: TranslationResources = {
connections: "Conexões",
agents: "Agentes",
metadata: "Metadados",
voice: "Voz",
workspaces: "Workspaces",
providers: "Provedores",
usage: "Uso",
@@ -2162,76 +2161,6 @@ export const ptBR: TranslationResources = {
docs: "Documentação",
saveError: "Não foi possível atualizar a geração de metadados",
},
voice: {
offline: "Conecte-se a este host para configurar a voz.",
updateHost: "Atualize o host para configurar a voz.",
saveError: "Não foi possível atualizar as configurações de voz",
provider: "Provedor",
providers: {
local: "Local",
openai: "OpenAI",
},
sttModel: "Modelo de fala para texto",
ttsModel: "Modelo de voz",
voice: "Voz",
notDownloadedOption: "{{label}} (não baixado)",
save: "Salvar",
retry: "Tentar novamente",
dictation: {
title: "Ditado",
enabled: "Ditado",
enabledHint: "Fale na caixa de mensagem em vez de digitar",
},
voiceMode: {
title: "Modo de voz",
info: "O modo de voz transcreve o que você diz e lê em voz alta as respostas do agente.",
enabled: "Modo de voz",
enabledHint: "Converse com seus agentes sem usar as mãos",
sttProvider: "Fala para texto",
ttsProvider: "Texto para fala",
},
openai: {
apiKey: "Chave de API",
apiKeyHint: "Salva no host e nunca mostrada novamente.",
keyPlaceholder: "sk-...",
replaceKeyPlaceholder: "Nova chave",
removeKey: "Remover",
removeKeyTitle: "Remover a chave de API da OpenAI?",
removeKeyMessage:
"Os recursos configurados com a OpenAI param de funcionar até você adicionar uma chave.",
baseUrl: "URL base",
baseUrlHint: "Para servidores compatíveis com a OpenAI. Deixe vazio para usar a OpenAI.",
missingKeyWarning: "A OpenAI está selecionada, mas nenhuma chave de API foi definida.",
},
models: {
loading: "Carregando modelos...",
loadError: "Não foi possível carregar os modelos locais",
downloaded: "Baixado",
inUse: "Em uso",
recommended: "Recomendado",
download: "Baixar",
downloading: "Baixando...",
downloadingPercent: "Baixando {{percent}}%",
extracting: "Extraindo...",
delete: "Excluir",
deleteAction: "Excluir {{label}}",
deleteTitle: "Excluir {{label}}?",
deleteMessage: "Isso libera {{size}}. Você pode baixá-lo de novo depois.",
languages: "Idiomas do {{label}}",
moreLanguages: "{{languages}} e mais {{count}}",
},
providerSettings: {
title: "Provedores",
info: "Modelos e credenciais de cada provedor. Os modelos locais rodam neste host e mostram tamanho e idiomas antes do download.",
localStt: "Fala para texto local",
localTts: "Texto para fala local",
openai: "OpenAI",
downloaded_one: "{{count}} modelo baixado",
downloaded_other: "{{count}} modelos baixados",
configured: "Configurado",
notConfigured: "Não configurado",
},
},
general: {
title: "Geral",
sending: "Envio",
-72
View File
@@ -2139,7 +2139,6 @@ export const ru: TranslationResources = {
connections: "Подключения",
agents: "Агенты",
metadata: "Метаданные",
voice: "Голос",
workspaces: "Рабочие пространства",
providers: "Провайдеры",
usage: "Использование",
@@ -2162,77 +2161,6 @@ export const ru: TranslationResources = {
docs: "Документация",
saveError: "Не удалось обновить настройки генерации метаданных",
},
voice: {
offline: "Подключитесь к этому хосту, чтобы настроить голос.",
updateHost: "Обновите хост, чтобы настроить голос.",
saveError: "Не удалось обновить голосовые настройки",
provider: "Провайдер",
providers: {
local: "Локально",
openai: "OpenAI",
},
sttModel: "Модель распознавания речи",
ttsModel: "Модель голоса",
voice: "Голос",
notDownloadedOption: "{{label}} (не скачана)",
save: "Сохранить",
retry: "Повторить",
dictation: {
title: "Диктовка",
enabled: "Диктовка",
enabledHint: "Говорите в поле сообщения вместо набора текста",
},
voiceMode: {
title: "Голосовой режим",
info: "Голосовой режим расшифровывает вашу речь и зачитывает ответы агента вслух.",
enabled: "Голосовой режим",
enabledHint: "Общайтесь с агентами без рук",
sttProvider: "Распознавание речи",
ttsProvider: "Синтез речи",
},
openai: {
apiKey: "API-ключ",
apiKeyHint: "Хранится на хосте и больше не показывается.",
keyPlaceholder: "sk-...",
replaceKeyPlaceholder: "Новый ключ",
removeKey: "Удалить",
removeKeyTitle: "Удалить API-ключ OpenAI?",
removeKeyMessage:
"Функции, настроенные на OpenAI, перестанут работать, пока вы не добавите ключ.",
baseUrl: "Базовый URL",
baseUrlHint:
"Для серверов, совместимых с OpenAI. Оставьте пустым, чтобы использовать OpenAI.",
missingKeyWarning: "Выбран OpenAI, но API-ключ не задан.",
},
models: {
loading: "Загрузка моделей...",
loadError: "Не удалось загрузить локальные модели",
downloaded: "Скачана",
inUse: "Используется",
recommended: "Рекомендуется",
download: "Скачать",
downloading: "Скачивание...",
downloadingPercent: "Скачивание {{percent}}%",
extracting: "Распаковка...",
delete: "Удалить",
deleteAction: "Удалить {{label}}",
deleteTitle: "Удалить {{label}}?",
deleteMessage: "Освободится {{size}}. Позже её можно скачать снова.",
languages: "Языки {{label}}",
moreLanguages: "{{languages}} и ещё {{count}}",
},
providerSettings: {
title: "Провайдеры",
info: "Модели и учётные данные каждого провайдера. Локальные модели работают на этом хосте; размер и языки видны до загрузки.",
localStt: "Локальное распознавание речи",
localTts: "Локальный синтез речи",
openai: "OpenAI",
downloaded_one: "Загружено моделей: {{count}}",
downloaded_other: "Загружено моделей: {{count}}",
configured: "Настроено",
notConfigured: "Не настроено",
},
},
general: {
title: "Основные",
sending: "Отправка",
-70
View File
@@ -2084,7 +2084,6 @@ export const zhCN: TranslationResources = {
connections: "连接",
agents: "Agents",
metadata: "元数据",
voice: "语音",
workspaces: "Workspaces",
providers: "Providers",
usage: "使用情况",
@@ -2106,75 +2105,6 @@ export const zhCN: TranslationResources = {
docs: "文档",
saveError: "无法更新元数据生成设置",
},
voice: {
offline: "连接到此主机以配置语音。",
updateHost: "请更新主机以配置语音。",
saveError: "无法更新语音设置",
provider: "提供方",
providers: {
local: "本地",
openai: "OpenAI",
},
sttModel: "语音转文字模型",
ttsModel: "语音模型",
voice: "音色",
notDownloadedOption: "{{label}}(未下载)",
save: "保存",
retry: "重试",
dictation: {
title: "听写",
enabled: "听写",
enabledHint: "对着消息框说话,无需打字",
},
voiceMode: {
title: "语音模式",
info: "语音模式会转写你说的话,并朗读智能体的回复。",
enabled: "语音模式",
enabledHint: "免手动与智能体对话",
sttProvider: "语音转文字",
ttsProvider: "文字转语音",
},
openai: {
apiKey: "API 密钥",
apiKeyHint: "保存在主机上,不会再次显示。",
keyPlaceholder: "sk-...",
replaceKeyPlaceholder: "新密钥",
removeKey: "移除",
removeKeyTitle: "移除 OpenAI API 密钥?",
removeKeyMessage: "在添加密钥之前,设为 OpenAI 的功能将无法使用。",
baseUrl: "基础 URL",
baseUrlHint: "用于兼容 OpenAI 的服务器。留空则使用 OpenAI。",
missingKeyWarning: "已选择 OpenAI,但未设置 API 密钥。",
},
models: {
loading: "正在加载模型...",
loadError: "无法加载本地模型",
downloaded: "已下载",
inUse: "使用中",
recommended: "推荐",
download: "下载",
downloading: "正在下载...",
downloadingPercent: "正在下载 {{percent}}%",
extracting: "正在解压...",
delete: "删除",
deleteAction: "删除 {{label}}",
deleteTitle: "删除 {{label}}?",
deleteMessage: "这将释放 {{size}}。之后可以重新下载。",
languages: "{{label}} 支持的语言",
moreLanguages: "{{languages}} 等另外 {{count}} 种",
},
providerSettings: {
title: "提供方",
info: "各提供方的模型和凭据。本地模型在此主机上运行,下载前会显示大小和支持的语言。",
localStt: "本地语音转文字",
localTts: "本地文字转语音",
openai: "OpenAI",
downloaded_one: "已下载 {{count}} 个模型",
downloaded_other: "已下载 {{count}} 个模型",
configured: "已配置",
notConfigured: "未配置",
},
},
general: {
title: "通用",
sending: "发送",
@@ -1,7 +1,10 @@
import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { useShallow } from "zustand/shallow";
import { formatAgentModeLabel } from "@/agent-controls/labels";
import { formatTokenCount } from "@/components/context-window-meter.utils";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useSessionStore } from "@/stores/session-store";
export interface AgentFactsInput {
models: AgentModelDefinition[] | null;
@@ -33,6 +36,32 @@ export function buildAgentFactsLine(input: AgentFactsInput): string | null {
return parts.join(" · ");
}
/** The facts line for a Paseo agent in the session store, kept current as the agent reports. */
export function useAgentFactsLine(serverId: string, agentId: string): string | null {
const agent = useSessionStore(
useShallow((state) => {
const current = state.sessions[serverId]?.agents.get(agentId);
return current
? {
provider: current.provider,
cwd: current.cwd,
runtimeModelId: current.runtimeInfo?.model ?? null,
configuredModelId: current.model ?? null,
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: current.thinkingOptionId ?? null,
availableModes: current.availableModes,
currentModeId: current.currentModeId,
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
}
: null;
}),
);
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
return agent ? buildAgentFactsLine({ ...agent, models }) : null;
}
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) {
const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0];
+2 -24
View File
@@ -60,9 +60,8 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
import { TimelineSyncStatus } from "@/timeline/sync-status";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import { buildAgentFactsLine } from "@/panels/agent-facts-line";
import { useAgentFactsLine } from "@/panels/agent-facts-line";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { useHasPluginComposerPills } from "@/plugins";
@@ -1384,28 +1383,7 @@ function TimelineSyncErrorCallout({
* without its composer.
*/
function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
const agent = useSessionStore(
useShallow((state) => {
const current = state.sessions[serverId]?.agents.get(agentId);
return current
? {
provider: current.provider,
cwd: current.cwd,
runtimeModelId: current.runtimeInfo?.model ?? null,
configuredModelId: current.model ?? null,
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: current.thinkingOptionId ?? null,
availableModes: current.availableModes,
currentModeId: current.currentModeId,
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
}
: null;
}),
);
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
const text = agent ? buildAgentFactsLine({ ...agent, models }) : null;
const text = useAgentFactsLine(serverId, agentId);
return text ? <PaneSubtitle text={text} testID="agent-pane-subtitle" /> : null;
}
@@ -15,6 +15,21 @@ import {
} from "./provider-selection";
describe("combined model selector data", () => {
it("shows a disconnected error for unfinished catalogs while keeping known models", () => {
const providers = buildSelectableProviderSelectorProviders(
[
snapshotEntry({ provider: "waiting", status: "loading", models: [] }),
snapshotEntry({ provider: "codex", status: "ready", models: [codexModel] }),
],
false,
);
expect(providers[0].modelSelection).toEqual({
kind: "error",
message: "Host is not connected",
});
expect(providers[1].modelSelection.kind).toBe("models");
});
const codexModel: AgentModelDefinition = {
provider: "codex",
id: "gpt-5.4",
@@ -367,7 +382,7 @@ describe("combined model selector data", () => {
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
isConnected: true,
}),
).toEqual({
ok: false,
@@ -387,7 +402,7 @@ describe("combined model selector data", () => {
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
isConnected: true,
}),
).toEqual({ ok: true });
});
@@ -426,7 +441,7 @@ describe("combined model selector data", () => {
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
isConnected: true,
}),
).toEqual({ ok: false, reason: "初始 prompt 必填" });
} finally {
@@ -103,6 +103,7 @@ function buildModelSelection(
function buildEntryModelSelection(
entry: ProviderSnapshotEntry,
label: string,
isConnected: boolean,
): ProviderModelSelection {
if ((entry.models?.length ?? 0) > 0) {
return buildModelSelection(entry.provider, label, entry.models ?? null);
@@ -111,7 +112,9 @@ function buildEntryModelSelection(
return buildModelSelection(entry.provider, label, entry.models ?? null);
}
if (entry.status === "loading") {
return { kind: "loading" };
return isConnected
? { kind: "loading" }
: { kind: "error", message: i18n.t("providerSelection.readiness.hostDisconnected") };
}
return {
kind: "error",
@@ -142,6 +145,7 @@ export function buildProviderSelectorProviders(input: {
export function buildSelectableProviderSelectorProviders(
entries: ProviderSnapshotEntry[] | undefined,
isConnected = true,
): ProviderSelectorProvider[] {
return (entries ?? [])
.filter((entry) => entry.enabled)
@@ -150,7 +154,7 @@ export function buildSelectableProviderSelectorProviders(
return {
id: entry.provider,
label,
modelSelection: buildEntryModelSelection(entry, label),
modelSelection: buildEntryModelSelection(entry, label, isConnected),
};
});
}
@@ -345,11 +349,14 @@ export function resolveSubmissionReadiness(input: {
};
autoSubmitConfig: { provider: string; model: string | null } | null;
workspaceDirectory: string | null;
hasClient: boolean;
isConnected: boolean;
}): ProviderSelectionReadiness {
if (!input.allowsEmptyAutoSubmit && !input.text.trim()) {
return { ok: false, reason: i18n.t("providerSelection.readiness.initialPromptRequired") };
}
if (!input.isConnected) {
return { ok: false, reason: i18n.t("providerSelection.readiness.hostDisconnected") };
}
if (input.providerCount === 0) {
return { ok: false, reason: i18n.t("providerSelection.readiness.noProviders") };
}
@@ -366,8 +373,5 @@ export function resolveSubmissionReadiness(input: {
if (!input.workspaceDirectory) {
return { ok: false, reason: i18n.t("providerSelection.readiness.workspaceDirectoryNotFound") };
}
if (!input.hasClient) {
return { ok: false, reason: i18n.t("providerSelection.readiness.hostDisconnected") };
}
return { ok: true };
}
@@ -182,6 +182,7 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
status: "done",
currentBranch: "main",
changeRequestNumber: null,
statusEnteredAt: null,
...overrides,
};
}
@@ -36,7 +36,6 @@ import {
Code2,
Smartphone,
Sparkles,
Mic,
Blocks,
Globe,
PanelLeft,
@@ -118,7 +117,6 @@ import { PluginSettingsContent } from "@/plugins/settings";
import { useInstalledPlugins } from "@/plugins/registry";
import { HostPluginsPage } from "@/screens/settings/plugins-page";
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
@@ -236,7 +234,6 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles },
{ id: "voice", labelKey: "settings.hostSections.voice", icon: Mic },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
@@ -259,8 +256,6 @@ function renderHostSettingsContent(
return <HostAgentsPage serverId={view.serverId} />;
case "metadata":
return <MetadataGenerationPage serverId={view.serverId} />;
case "voice":
return <HostVoicePage serverId={view.serverId} />;
case "workspaces":
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers":
@@ -1,71 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { isLocalModelBusy } from "./voice-settings";
const DOWNLOAD_POLL_INTERVAL_MS = 1000;
const localSpeechModelsQueryKey = (serverId: string) => ["speech-local-models", serverId] as const;
export interface LocalSpeechModels {
models: LocalSpeechModel[];
isLoading: boolean;
error: Error | null;
refresh: () => void;
download: (modelId: string) => Promise<void>;
remove: (modelId: string) => Promise<void>;
}
// The daemon reports download progress through the list, so the list polls while any
// model is downloading or extracting.
export function useLocalSpeechModels(serverId: string): LocalSpeechModels {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useFetchQuery({
queryKey: localSpeechModelsQueryKey(serverId),
enabled: Boolean(client && isConnected),
dataShape: "list",
staleTimeMs: 0,
queryFn: () => {
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
return client.listLocalSpeechModels();
},
refetchInterval: (current) =>
current.state.data?.some(isLocalModelBusy) ? DOWNLOAD_POLL_INTERVAL_MS : false,
});
const { refetch } = query;
const refresh = useCallback(() => {
void refetch();
}, [refetch]);
const download = useCallback(
async (modelId: string) => {
if (!client) return;
await client.downloadLocalSpeechModel(modelId);
await refetch();
},
[client, refetch],
);
const remove = useCallback(
async (modelId: string) => {
if (!client) return;
await client.deleteLocalSpeechModel(modelId);
await refetch();
},
[client, refetch],
);
return {
models: query.data ?? [],
isLoading: query.isLoading,
error: query.error,
refresh,
download,
remove,
};
}
@@ -1,703 +0,0 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert as NativeAlert, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { StyleSheet } from "react-native-unistyles";
import { Download, Languages, Trash2 } from "lucide-react-native";
import type {
LocalSpeechModel,
SpeechSettings,
SpeechSettingsPatch,
} from "@getpaseo/protocol/speech/rpc-schemas";
import {
SettingsCard,
SettingsCollapsibleRow,
SettingsInfoTip,
SettingsRow,
SettingsSection,
SettingsSelect,
} from "@/components/settings";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { FormTextInput } from "@/components/ui/form-field";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { StatusBadge } from "@/components/ui/status-badge";
import { Switch } from "@/components/ui/switch";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useLocalSpeechModels, type LocalSpeechModels } from "./use-local-speech-models";
import {
SPEECH_PROVIDERS,
buildModelOptions,
buildOpenAiVoiceOptions,
formatLanguages,
formatModelSize,
getLocalModelState,
resolveSelectedModel,
resolveSelectedVoice,
summarizeLocalModels,
toSpeechProvider,
usesOpenAi,
type SpeechModelKind,
type SpeechProvider,
} from "./voice-settings";
type SavePatch = (patch: SpeechSettingsPatch) => void;
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function HostVoicePage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "speechSettings");
const { config, patchConfig } = useDaemonConfig(serverId);
const localModels = useLocalSpeechModels(serverId);
const { refresh: refreshModels } = localModels;
const save = useCallback<SavePatch>(
(patch) => {
patchConfig({ speech: patch })
.then(refreshModels)
.catch((error: unknown) => {
NativeAlert.alert(t("settings.voice.saveError"), errorMessage(error));
});
},
[patchConfig, refreshModels, t],
);
if (!isConnected) {
return <Alert size="md" variant="warning" title={t("settings.voice.offline")} />;
}
if (!supported) {
return <Alert size="md" variant="warning" title={t("settings.voice.updateHost")} />;
}
const speech = config?.speech;
if (!speech) {
return (
<View style={styles.loading}>
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
</View>
);
}
return (
<View testID="voice-settings">
<DictationSection speech={speech} localModels={localModels.models} save={save} />
<VoiceModeSection speech={speech} localModels={localModels.models} save={save} />
<ProvidersSection speech={speech} localModels={localModels} save={save} />
</View>
);
}
// ---------------------------------------------------------------------------
// Feature sections
// ---------------------------------------------------------------------------
interface FeatureSectionProps {
speech: SpeechSettings;
localModels: LocalSpeechModel[];
save: SavePatch;
}
function DictationSection({ speech, localModels, save }: FeatureSectionProps) {
const { t } = useTranslation();
const { dictation } = speech;
const setEnabled = useCallback((enabled: boolean) => save({ dictation: { enabled } }), [save]);
const setProvider = useCallback(
(provider: SpeechProvider) => save({ dictation: { stt: { provider } } }),
[save],
);
const setModel = useCallback((model: string) => save({ dictation: { stt: { model } } }), [save]);
return (
<SettingsSection title={t("settings.voice.dictation.title")} testID="voice-dictation">
<SettingsCard>
<SettingsRow
label={t("settings.voice.dictation.enabled")}
hint={t("settings.voice.dictation.enabledHint")}
>
<Switch
value={dictation.enabled}
onValueChange={setEnabled}
accessibilityLabel={t("settings.voice.dictation.enabled")}
/>
</SettingsRow>
{dictation.enabled ? (
<ProviderRow
label={t("settings.voice.provider")}
value={toSpeechProvider(dictation.stt.provider)}
onChange={setProvider}
testID="voice-dictation-provider"
/>
) : null}
{dictation.enabled ? (
<ModelRow
kind="stt"
provider={toSpeechProvider(dictation.stt.provider)}
model={dictation.stt.model}
localModels={localModels}
onChange={setModel}
/>
) : null}
</SettingsCard>
</SettingsSection>
);
}
function VoiceModeSection({ speech, localModels, save }: FeatureSectionProps) {
const { t } = useTranslation();
const { voiceMode } = speech;
const sttProvider = toSpeechProvider(voiceMode.stt.provider);
const ttsProvider = toSpeechProvider(voiceMode.tts.provider);
const setEnabled = useCallback((enabled: boolean) => save({ voiceMode: { enabled } }), [save]);
const setSttProvider = useCallback(
(provider: SpeechProvider) => save({ voiceMode: { stt: { provider } } }),
[save],
);
const setSttModel = useCallback(
(model: string) => save({ voiceMode: { stt: { model } } }),
[save],
);
const setTtsProvider = useCallback(
(provider: SpeechProvider) => save({ voiceMode: { tts: { provider } } }),
[save],
);
const setTtsModel = useCallback(
(model: string) => save({ voiceMode: { tts: { model } } }),
[save],
);
const setTtsVoice = useCallback(
(voice: string) => save({ voiceMode: { tts: { voice } } }),
[save],
);
const voiceOptions = useMemo(
() => buildOpenAiVoiceOptions(resolveSelectedVoice(voiceMode.tts.voice)),
[voiceMode.tts.voice],
);
return (
<SettingsSection
title={t("settings.voice.voiceMode.title")}
info={t("settings.voice.voiceMode.info")}
testID="voice-voice-mode"
>
<SettingsCard>
<SettingsRow
label={t("settings.voice.voiceMode.enabled")}
hint={t("settings.voice.voiceMode.enabledHint")}
>
<Switch
value={voiceMode.enabled}
onValueChange={setEnabled}
accessibilityLabel={t("settings.voice.voiceMode.enabled")}
/>
</SettingsRow>
{voiceMode.enabled ? (
<ProviderRow
label={t("settings.voice.voiceMode.sttProvider")}
value={sttProvider}
onChange={setSttProvider}
testID="voice-mode-stt-provider"
/>
) : null}
{voiceMode.enabled ? (
<ModelRow
kind="stt"
provider={sttProvider}
model={voiceMode.stt.model}
localModels={localModels}
onChange={setSttModel}
/>
) : null}
{voiceMode.enabled ? (
<ProviderRow
label={t("settings.voice.voiceMode.ttsProvider")}
value={ttsProvider}
onChange={setTtsProvider}
testID="voice-mode-tts-provider"
/>
) : null}
{voiceMode.enabled ? (
<ModelRow
kind="tts"
provider={ttsProvider}
model={voiceMode.tts.model}
localModels={localModels}
onChange={setTtsModel}
/>
) : null}
{voiceMode.enabled && ttsProvider === "openai" ? (
<SettingsSelect
label={t("settings.voice.voice")}
value={resolveSelectedVoice(voiceMode.tts.voice)}
options={voiceOptions}
onValueChange={setTtsVoice}
/>
) : null}
</SettingsCard>
</SettingsSection>
);
}
function ProviderRow({
label,
value,
onChange,
testID,
}: {
label: string;
value: SpeechProvider;
onChange: (provider: SpeechProvider) => void;
testID: string;
}) {
const { t } = useTranslation();
const options = useMemo(
() =>
SPEECH_PROVIDERS.map((provider) => ({
value: provider,
label: t(`settings.voice.providers.${provider}`),
})),
[t],
);
return (
<SettingsSelect
label={label}
value={value}
options={options}
onValueChange={onChange}
testID={testID}
/>
);
}
function ModelRow({
kind,
provider,
model,
localModels,
onChange,
}: {
kind: SpeechModelKind;
provider: SpeechProvider;
model: string | undefined;
localModels: LocalSpeechModel[];
onChange: (model: string) => void;
}) {
const { t } = useTranslation();
const selected = resolveSelectedModel({ kind, provider, model, localModels });
const options = useMemo(
() =>
buildModelOptions({
kind,
provider,
selected,
localModels,
notDownloadedLabel: (label) => t("settings.voice.notDownloadedOption", { label }),
}),
[kind, localModels, provider, selected, t],
);
return (
<SettingsSelect
label={kind === "stt" ? t("settings.voice.sttModel") : t("settings.voice.ttsModel")}
value={selected}
options={options}
onValueChange={onChange}
/>
);
}
// ---------------------------------------------------------------------------
// Providers: one collapsible row per provider, each with a one-line summary
// ---------------------------------------------------------------------------
function ProvidersSection({
speech,
localModels,
save,
}: {
speech: SpeechSettings;
localModels: LocalSpeechModels;
save: SavePatch;
}) {
const { t } = useTranslation();
const missingKey = usesOpenAi(speech) && !speech.openai.apiKeyConfigured;
return (
<SettingsSection
title={t("settings.voice.providerSettings.title")}
info={t("settings.voice.providerSettings.info")}
testID="voice-providers"
>
{missingKey ? (
<View style={styles.sectionAlert}>
<Alert size="sm" variant="warning" title={t("settings.voice.openai.missingKeyWarning")} />
</View>
) : null}
<SettingsCard>
<LocalProviderRow kind="stt" localModels={localModels} />
<LocalProviderRow kind="tts" localModels={localModels} />
<OpenAiProviderRow speech={speech} save={save} />
</SettingsCard>
</SettingsSection>
);
}
function LocalProviderRow({
kind,
localModels,
}: {
kind: SpeechModelKind;
localModels: LocalSpeechModels;
}) {
const { t } = useTranslation();
const { models, isLoading, error, refresh, download, remove } = localModels;
const kindModels = useMemo(() => models.filter((model) => model.kind === kind), [kind, models]);
const label =
kind === "stt"
? t("settings.voice.providerSettings.localStt")
: t("settings.voice.providerSettings.localTts");
if (error && kindModels.length === 0) {
return (
<SettingsCollapsibleRow
label={label}
summary={t("settings.voice.models.loadError")}
testID={`voice-provider-local-${kind}`}
>
<SettingsRow label={t("settings.voice.models.loadError")} hint={error.message}>
<Button variant="outline" onPress={refresh}>
{t("settings.voice.retry")}
</Button>
</SettingsRow>
</SettingsCollapsibleRow>
);
}
return (
<SettingsCollapsibleRow
label={label}
summary={isLoading ? t("settings.voice.models.loading") : localSummaryLabel(models, kind, t)}
testID={`voice-provider-local-${kind}`}
>
{kindModels.map((model) => (
<LocalModelRow key={model.id} model={model} onDownload={download} onDelete={remove} />
))}
</SettingsCollapsibleRow>
);
}
function localSummaryLabel(
models: readonly LocalSpeechModel[],
kind: SpeechModelKind,
t: TFunction,
): string {
const summary = summarizeLocalModels(models, kind);
switch (summary.kind) {
case "downloading":
return summary.percent === null
? t("settings.voice.models.downloading")
: t("settings.voice.models.downloadingPercent", { percent: summary.percent });
case "extracting":
return t("settings.voice.models.extracting");
case "downloaded":
return t("settings.voice.providerSettings.downloaded", { count: summary.count });
}
}
function OpenAiProviderRow({ speech, save }: { speech: SpeechSettings; save: SavePatch }) {
const { t } = useTranslation();
const { apiKeyConfigured, baseUrl } = speech.openai;
const keyInput = useRef<EditingTextInputHandle>(null);
const [draftKey, setDraftKey] = useState("");
const [draftBaseUrl, setDraftBaseUrl] = useState<string | null>(null);
const saveKey = useCallback(() => {
save({ openai: { apiKey: draftKey } });
keyInput.current?.replaceText("");
setDraftKey("");
}, [draftKey, save]);
const removeKey = useCallback(() => {
void confirmDialog({
title: t("settings.voice.openai.removeKeyTitle"),
message: t("settings.voice.openai.removeKeyMessage"),
confirmLabel: t("settings.voice.openai.removeKey"),
destructive: true,
}).then((confirmed) => {
if (confirmed) save({ openai: { apiKey: null } });
return undefined;
});
}, [save, t]);
const saveBaseUrl = useCallback(() => {
if (draftBaseUrl === null) return;
save({ openai: { baseUrl: draftBaseUrl.trim() || null } });
setDraftBaseUrl(null);
}, [draftBaseUrl, save]);
const apiKeyInfo = useMemo(
() => (
<SettingsInfoTip
title={t("settings.voice.openai.apiKey")}
info={t("settings.voice.openai.apiKeyHint")}
/>
),
[t],
);
const baseUrlInfo = useMemo(
() => (
<SettingsInfoTip
title={t("settings.voice.openai.baseUrl")}
info={t("settings.voice.openai.baseUrlHint")}
/>
),
[t],
);
return (
<SettingsCollapsibleRow
label={t("settings.voice.providerSettings.openai")}
summary={
apiKeyConfigured
? t("settings.voice.providerSettings.configured")
: t("settings.voice.providerSettings.notConfigured")
}
testID="voice-provider-openai"
>
<SettingsRow label={t("settings.voice.openai.apiKey")} labelAccessory={apiKeyInfo}>
<View style={styles.inputControls}>
<FormTextInput
ref={keyInput}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
placeholder={
apiKeyConfigured
? t("settings.voice.openai.replaceKeyPlaceholder")
: t("settings.voice.openai.keyPlaceholder")
}
onChangeText={setDraftKey}
accessibilityLabel={t("settings.voice.openai.apiKey")}
style={styles.input}
testID="voice-openai-key-input"
/>
<Button
variant="outline"
onPress={saveKey}
disabled={draftKey.trim().length === 0}
testID="voice-openai-key-save"
>
{t("settings.voice.save")}
</Button>
{apiKeyConfigured ? (
<Button variant="ghost" onPress={removeKey}>
{t("settings.voice.openai.removeKey")}
</Button>
) : null}
</View>
</SettingsRow>
<SettingsRow label={t("settings.voice.openai.baseUrl")} labelAccessory={baseUrlInfo}>
<View style={styles.inputControls}>
<FormTextInput
autoCapitalize="none"
autoCorrect={false}
initialValue={baseUrl ?? ""}
placeholder="https://api.openai.com/v1"
onChangeText={setDraftBaseUrl}
accessibilityLabel={t("settings.voice.openai.baseUrl")}
style={styles.input}
/>
<Button
variant="outline"
onPress={saveBaseUrl}
disabled={draftBaseUrl === null || draftBaseUrl.trim() === (baseUrl ?? "")}
>
{t("settings.voice.save")}
</Button>
</View>
</SettingsRow>
</SettingsCollapsibleRow>
);
}
function LocalModelRow({
model,
onDownload,
onDelete,
}: {
model: LocalSpeechModel;
onDownload: (modelId: string) => Promise<void>;
onDelete: (modelId: string) => Promise<void>;
}) {
const { t, i18n } = useTranslation();
const state = getLocalModelState(model);
const [actionError, setActionError] = useState<string | null>(null);
const download = useCallback(() => {
setActionError(null);
onDownload(model.id).catch((error: unknown) => setActionError(errorMessage(error)));
}, [model.id, onDownload]);
const remove = useCallback(() => {
void confirmDialog({
title: t("settings.voice.models.deleteTitle", { label: model.label }),
message: t("settings.voice.models.deleteMessage", {
size: formatModelSize(model.downloadSizeBytes),
}),
confirmLabel: t("settings.voice.models.delete"),
destructive: true,
})
.then((confirmed) => {
if (!confirmed) return undefined;
setActionError(null);
return onDelete(model.id);
})
.catch((error: unknown) => setActionError(errorMessage(error)));
}, [model.downloadSizeBytes, model.id, model.label, onDelete, t]);
const languages = formatLanguages({
languages: model.languages,
locale: i18n.language,
formatMore: (listed, count) =>
t("settings.voice.models.moreLanguages", { languages: listed, count }),
});
const details = useMemo(
() => (
<View style={styles.details}>
<SettingsInfoTip
title={model.label}
info={model.description}
testID={`voice-model-${model.id}-description`}
/>
<SettingsInfoTip
title={model.label}
info={languages}
icon={Languages}
accessibilityLabel={t("settings.voice.models.languages", { label: model.label })}
testID={`voice-model-${model.id}-languages`}
/>
<Text style={styles.modelSize}>{formatModelSize(model.downloadSizeBytes)}</Text>
{state.kind === "installed" ? (
<StatusBadge
size="xs"
variant="success"
label={
model.inUse ? t("settings.voice.models.inUse") : t("settings.voice.models.downloaded")
}
/>
) : null}
{model.isDefault ? (
<StatusBadge size="xs" label={t("settings.voice.models.recommended")} />
) : null}
</View>
),
[
languages,
model.description,
model.downloadSizeBytes,
model.id,
model.inUse,
model.isDefault,
model.label,
state.kind,
t,
],
);
let control = null;
if (state.kind === "downloading") {
control = (
<View style={styles.progress}>
<LoadingSpinner size="small" color={styles.spinnerColor.color} />
<Text style={styles.progressText}>
{state.percent === null
? t("settings.voice.models.downloading")
: t("settings.voice.models.downloadingPercent", { percent: state.percent })}
</Text>
</View>
);
} else if (state.kind === "extracting") {
control = (
<View style={styles.progress}>
<LoadingSpinner size="small" color={styles.spinnerColor.color} />
<Text style={styles.progressText}>{t("settings.voice.models.extracting")}</Text>
</View>
);
} else if (state.kind === "installed") {
control = model.inUse ? null : (
<Button
variant="ghost"
leftIcon={Trash2}
onPress={remove}
accessibilityLabel={t("settings.voice.models.deleteAction", { label: model.label })}
testID={`voice-model-delete-${model.id}`}
>
{t("settings.voice.models.delete")}
</Button>
);
} else {
control = (
<Button
variant="outline"
leftIcon={Download}
onPress={download}
testID={`voice-model-download-${model.id}`}
>
{state.kind === "failed" ? t("settings.voice.retry") : t("settings.voice.models.download")}
</Button>
);
}
return (
<SettingsRow
label={model.label}
labelAccessory={details}
error={actionError ?? (state.kind === "failed" ? state.error : null)}
testID={`voice-model-${model.id}`}
>
{control}
</SettingsRow>
);
}
const styles = StyleSheet.create((theme) => ({
loading: {
paddingVertical: theme.spacing[8],
alignItems: "center",
},
spinnerColor: {
color: theme.colors.foregroundMuted,
},
sectionAlert: {
marginBottom: theme.spacing[3],
},
inputControls: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
input: {
minWidth: 220,
},
details: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
modelSize: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontVariant: ["tabular-nums"],
},
progress: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
progressText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontVariant: ["tabular-nums"],
},
}));
@@ -1,126 +0,0 @@
import { describe, expect, it } from "vitest";
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
import {
buildModelOptions,
formatLanguages,
formatModelSize,
getLocalModelState,
resolveSelectedModel,
summarizeLocalModels,
} from "./voice-settings";
function model(overrides: Partial<LocalSpeechModel>): LocalSpeechModel {
return {
id: "parakeet-tdt-0.6b-v2-int8",
kind: "stt",
label: "Parakeet v2",
description: "Accurate English transcription.",
languages: ["en"],
downloadSizeBytes: 482_468_385,
isDefault: true,
inUse: true,
status: "installed",
...overrides,
};
}
const catalog = [
model({}),
model({
id: "parakeet-tdt-110m-en-int8",
label: "Parakeet Small",
isDefault: false,
status: "not_installed",
inUse: false,
}),
model({ id: "kokoro-en-v0_19", kind: "tts", label: "Kokoro", inUse: false }),
];
describe("voice settings", () => {
it("falls back to the catalog default when no local model is saved", () => {
expect(
resolveSelectedModel({
kind: "stt",
provider: "local",
model: undefined,
localModels: catalog,
}),
).toBe("parakeet-tdt-0.6b-v2-int8");
expect(
resolveSelectedModel({
kind: "tts",
provider: "openai",
model: undefined,
localModels: catalog,
}),
).toBe("tts-1");
});
it("marks local models that still need a download", () => {
expect(
buildModelOptions({
kind: "stt",
provider: "local",
selected: "parakeet-tdt-0.6b-v2-int8",
localModels: catalog,
notDownloadedLabel: (label) => `${label} (not downloaded)`,
}),
).toEqual([
{ value: "parakeet-tdt-0.6b-v2-int8", label: "Parakeet v2" },
{ value: "parakeet-tdt-110m-en-int8", label: "Parakeet Small (not downloaded)" },
]);
});
it("keeps a custom OpenAI-compatible model id selectable", () => {
const options = buildModelOptions({
kind: "stt",
provider: "openai",
selected: "my-whisper",
localModels: catalog,
notDownloadedLabel: (label) => label,
});
expect(options.at(-1)).toEqual({ value: "my-whisper", label: "my-whisper" });
});
it("reports download progress as a percentage", () => {
expect(
getLocalModelState(model({ status: "downloading", receivedBytes: 241, totalBytes: 482 })),
).toEqual({ kind: "downloading", percent: 50 });
expect(getLocalModelState(model({ status: "downloading" }))).toEqual({
kind: "downloading",
percent: null,
});
});
it("summarizes each local provider by its downloads", () => {
expect(summarizeLocalModels(catalog, "stt")).toEqual({ kind: "downloaded", count: 1 });
expect(summarizeLocalModels(catalog, "tts")).toEqual({ kind: "downloaded", count: 1 });
const downloading = [
...catalog,
model({
id: "parakeet-tdt-0.6b-v3-int8",
status: "downloading",
receivedBytes: 1,
totalBytes: 4,
}),
];
expect(summarizeLocalModels(downloading, "stt")).toEqual({ kind: "downloading", percent: 25 });
expect(summarizeLocalModels(downloading, "tts")).toEqual({ kind: "downloaded", count: 1 });
});
it("formats download sizes", () => {
expect(formatModelSize(482_468_385)).toBe("482 MB");
expect(formatModelSize(1_250_000_000)).toBe("1.3 GB");
});
it("names a short language list and counts the rest of a long one", () => {
const formatMore = (languages: string, count: number) => `${languages} and ${count} more`;
expect(formatLanguages({ languages: ["en", "de"], locale: "en", formatMore })).toBe(
"English, German",
);
const whisper = ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca"];
expect(formatLanguages({ languages: whisper, locale: "en", formatMore })).toBe(
"English, Chinese, German, Spanish, Russian, Korean, French, Japanese, Portuguese, Turkish and 2 more",
);
});
});
@@ -1,173 +0,0 @@
import type { LocalSpeechModel, SpeechSettings } from "@getpaseo/protocol/speech/rpc-schemas";
// What the Voice page needs to know about speech providers and models, kept free of React
// so the option lists and model states are testable on their own.
export type SpeechProvider = "local" | "openai";
export type SpeechModelKind = "stt" | "tts";
export const SPEECH_PROVIDERS: readonly SpeechProvider[] = ["local", "openai"];
// OpenAI accepts free-form ids so self-hosted servers can use their own; these are the
// ones the page offers. A saved id outside the list is still shown as an option.
const OPENAI_MODELS: Record<SpeechModelKind, { ids: readonly string[]; defaultId: string }> = {
stt: {
ids: ["whisper-1", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"],
defaultId: "whisper-1",
},
tts: { ids: ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], defaultId: "tts-1" },
};
const OPENAI_VOICES = ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"];
const DEFAULT_OPENAI_VOICE = "alloy";
export interface SpeechModelOption {
value: string;
label: string;
}
export function toSpeechProvider(provider: string): SpeechProvider {
return provider === "openai" ? "openai" : "local";
}
export function resolveSelectedModel(input: {
kind: SpeechModelKind;
provider: SpeechProvider;
model: string | undefined;
localModels: readonly LocalSpeechModel[];
}): string {
if (input.model) return input.model;
if (input.provider === "openai") return OPENAI_MODELS[input.kind].defaultId;
return input.localModels.find((m) => m.kind === input.kind && m.isDefault)?.id ?? "";
}
export function buildModelOptions(input: {
kind: SpeechModelKind;
provider: SpeechProvider;
selected: string;
localModels: readonly LocalSpeechModel[];
notDownloadedLabel: (label: string) => string;
}): SpeechModelOption[] {
if (input.provider === "openai") {
return withSelected(
OPENAI_MODELS[input.kind].ids.map((id) => ({ value: id, label: id })),
input.selected,
);
}
return input.localModels
.filter((model) => model.kind === input.kind)
.map((model) => ({
value: model.id,
label: model.status === "installed" ? model.label : input.notDownloadedLabel(model.label),
}));
}
export function resolveSelectedVoice(voice: string | undefined): string {
return voice ?? DEFAULT_OPENAI_VOICE;
}
export function buildOpenAiVoiceOptions(selected: string): SpeechModelOption[] {
return withSelected(
OPENAI_VOICES.map((voice) => ({ value: voice, label: voice })),
selected,
);
}
function withSelected(options: SpeechModelOption[], selected: string): SpeechModelOption[] {
if (!selected || options.some((option) => option.value === selected)) return options;
return [...options, { value: selected, label: selected }];
}
export function usesOpenAi(settings: SpeechSettings): boolean {
return (
(settings.dictation.enabled && settings.dictation.stt.provider === "openai") ||
(settings.voiceMode.enabled &&
(settings.voiceMode.stt.provider === "openai" ||
settings.voiceMode.tts.provider === "openai"))
);
}
export type LocalModelState =
| { kind: "installed" }
| { kind: "not_installed" }
| { kind: "downloading"; percent: number | null }
| { kind: "extracting" }
| { kind: "failed"; error: string };
export function getLocalModelState(model: LocalSpeechModel): LocalModelState {
switch (model.status) {
case "installed":
return { kind: "installed" };
case "downloading": {
const total = model.totalBytes ?? 0;
const received = model.receivedBytes ?? 0;
return {
kind: "downloading",
percent: total > 0 ? Math.min(100, Math.floor((received / total) * 100)) : null,
};
}
case "extracting":
return { kind: "extracting" };
case "failed":
return { kind: "failed", error: model.error ?? "" };
default:
return { kind: "not_installed" };
}
}
export function isLocalModelBusy(model: LocalSpeechModel): boolean {
return model.status === "downloading" || model.status === "extracting";
}
// What a collapsed local provider row says: an active download wins over the count.
export type LocalModelsSummary =
| { kind: "downloading"; percent: number | null }
| { kind: "extracting" }
| { kind: "downloaded"; count: number };
export function summarizeLocalModels(
models: readonly LocalSpeechModel[],
kind: SpeechModelKind,
): LocalModelsSummary {
const kindModels = models.filter((model) => model.kind === kind);
const busy = kindModels.find(isLocalModelBusy);
if (busy) {
const state = getLocalModelState(busy);
if (state.kind === "downloading") return state;
return { kind: "extracting" };
}
return {
kind: "downloaded",
count: kindModels.filter((model) => model.status === "installed").length,
};
}
export function formatModelSize(bytes: number): string {
const megabytes = bytes / 1_000_000;
if (megabytes >= 1000) return `${(megabytes / 1000).toFixed(1)} GB`;
return `${Math.round(megabytes)} MB`;
}
// Multilingual models list close to a hundred languages; past this many the tooltip names
// the first ones and counts the rest.
const MAX_LISTED_LANGUAGES = 10;
export function formatLanguages(input: {
languages: readonly string[];
locale: string;
formatMore: (languages: string, count: number) => string;
}): string {
const names = languageNames(input.locale);
const listed = input.languages
.slice(0, MAX_LISTED_LANGUAGES)
.map((code) => names?.of(code) ?? code.toUpperCase())
.join(", ");
const hidden = input.languages.length - MAX_LISTED_LANGUAGES;
return hidden > 0 ? input.formatMore(listed, hidden) : listed;
}
// Hermes ships without Intl.DisplayNames; the language code is the fallback label there.
function languageNames(locale: string): Intl.DisplayNames | null {
if (typeof Intl.DisplayNames !== "function") return null;
return new Intl.DisplayNames([locale], { type: "language" });
}
+8 -1
View File
@@ -26,15 +26,22 @@ export const density = {
tightRowHeight: byLayout(36, 28),
/** A square icon-only button that sits in a row or toolbar. */
iconButton: byLayout(44, 28),
/** The space between two blocks of rich text, such as Markdown paragraphs, code and images. */
blockGap: byLayout(12, 16),
};
/** Resolves a density value for a prop, which Unistyles cannot resolve on its own. */
export function resolveDensity<T>(rt: { breakpoint?: string }, value: { xs: T; md: T }): T {
return isCompactBreakpoint(rt.breakpoint) ? value.xs : value.md;
}
/** The width or height of a slot that holds an icon of `size`. */
export function densityIconBox(size: DensityIconSize) {
return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
}
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
return resolveDensity(rt, densityIconBox(size));
}
/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */
+44 -10
View File
@@ -13,6 +13,7 @@ import {
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import type { Theme } from "@/styles/theme";
import { useAgentFactsLine } from "@/panels/agent-facts-line";
import { getPanelManifest } from "@/panels/panel-manifest";
import type { SubagentRow } from "./select";
import type { ArchiveFinishedStatus } from "./use-archive-finished";
@@ -219,14 +220,16 @@ function SubagentsTrackRow({
({ active }: { active: boolean }) => (
<>
<WorkspaceTabIcon presentation={presentation} backdrop={active ? "surface2" : "surface1"} />
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
{presentation.subtitle ? (
<Text style={styles.rowTrailing} numberOfLines={1}>
{presentation.subtitle}
<View style={styles.rowText}>
<Text style={styles.rowTitle} numberOfLines={1}>
{displayLabel}
</Text>
) : null}
{row.kind === "paseo" ? (
<PaseoSubagentFacts serverId={serverId} agentId={row.id} />
) : (
<SubagentFacts rowId={row.id} text={presentation.subtitle} />
)}
</View>
{row.kind === "paseo" ? (
<SubagentRowActions
rowId={row.id}
@@ -247,6 +250,7 @@ function SubagentsTrackRow({
presentation,
row.kind,
row.id,
serverId,
],
);
@@ -262,6 +266,21 @@ function SubagentsTrackRow({
);
}
/** The same facts line the agent's sheet shows when it opens without its composer. */
function PaseoSubagentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
const text = useAgentFactsLine(serverId, agentId);
return <SubagentFacts rowId={agentId} text={text} />;
}
function SubagentFacts({ rowId, text }: { rowId: string; text: string | null }) {
if (!text) return null;
return (
<Text style={styles.rowFacts} numberOfLines={1} testID={`subagents-track-row-facts-${rowId}`}>
{text}
</Text>
);
}
function SubagentRowActions({
rowId,
displayLabel,
@@ -360,9 +379,24 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
},
// Trailing metadata — provider context on a subagent row, progress on the archive row. No width
// cap: the panel's own ceiling bounds it. It shrinks twice as fast as the label, so a wordy
// provider subtitle gives way first instead of squeezing the thing that names the row.
// Title over facts. The facts sit under the title rather than beside it, so a long task name
// keeps the row's full width and neither line truncates the other.
rowText: {
flexGrow: 1,
flexShrink: 1,
flexBasis: "auto",
minWidth: 0,
},
rowTitle: {
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
},
rowFacts: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
// Progress on the archive row. It shrinks twice as fast as the label, so a long retry count
// gives way first instead of squeezing the thing that names the row.
rowTrailing: {
flexShrink: 2,
minWidth: 0,
-1
View File
@@ -517,7 +517,6 @@ export const HOST_SECTION_SLUGS = [
"pair-device",
"agents",
"metadata",
"voice",
"workspaces",
"providers",
"usage",
+2
View File
@@ -13,6 +13,7 @@ export interface WorkspaceSummary {
archivingAt?: string;
/** Pull/merge request number backing this workspace, so a query can find it. */
changeRequestNumber: number | null;
statusEnteredAt: Date | null;
}
export interface ProjectHostEntry {
@@ -161,6 +162,7 @@ function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
currentBranch: currentBranch && currentBranch !== "HEAD" ? currentBranch : null,
...(workspace.archivingAt ? { archivingAt: workspace.archivingAt } : {}),
changeRequestNumber: toChangeRequestNumber(workspace),
statusEnteredAt: workspace.statusEnteredAt,
};
}
@@ -65,6 +65,45 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
}
describe("buildSidebarProjectRowModel", () => {
it("prefers the active host when the grouped project can create there", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" },
],
}),
collapsed: false,
preferredServerId: "host-b",
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "host-b", projectId: "project-host-b", iconWorkingDir: "/repo/b" },
});
});
it.each(["host-b", "another-host"])(
"keeps the first eligible placement when preferred host %s cannot create here",
(preferredServerId) => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "unsupported" },
],
}),
collapsed: false,
preferredServerId,
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "host-a", projectId: "project-host-a", iconWorkingDir: "/repo/a" },
});
},
);
it("renders a non-git single-workspace project as an expandable section", () => {
const result = buildSidebarProjectRowModel({
project: project({
@@ -85,7 +85,9 @@ export function resolveSidebarProjectLocalPath(
function resolveNewWorkspaceTarget(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
preferredServerId: string | null | undefined,
): SidebarProjectHostTarget | null {
let fallbackTarget: SidebarProjectHostTarget | null = null;
for (const host of project.hosts) {
if (
host.worktreeSupport === "unsupported" &&
@@ -94,22 +96,30 @@ function resolveNewWorkspaceTarget(
continue;
}
const target = hostTarget(host);
if (target) return target;
if (!target) continue;
if (host.serverId === preferredServerId) return target;
fallbackTarget ??= target;
}
return null;
return fallbackTarget;
}
function projectTrailingAction(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
preferredServerId: string | null | undefined,
): SidebarProjectTrailingAction {
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
const target = resolveNewWorkspaceTarget(
project,
supportsMultiplicityByServerId,
preferredServerId,
);
return target ? { kind: "new_workspace", target } : { kind: "none" };
}
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
preferredServerId?: string | null;
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
}): SidebarProjectRowModel {
return {
@@ -118,6 +128,7 @@ export function buildSidebarProjectRowModel(input: {
trailingAction: projectTrailingAction(
input.project,
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
input.preferredServerId,
),
};
}
-3
View File
@@ -82,7 +82,6 @@ export default defineConfig({
// Bundle the CJS dependencies of the excluded gesture-handler package for the browser.
include: [
"react/jsx-runtime",
"react/jsx-dev-runtime",
"react-native-gesture-handler > hoist-non-react-statics",
"react-native-gesture-handler > invariant",
],
@@ -92,8 +91,6 @@ export default defineConfig({
"react-native-keyboard-controller",
],
},
// babel-preset-expo compiles JSX with the automatic runtime, so app modules don't import React.
esbuild: { jsx: "automatic" },
// The globals a React Native bundler defines, which esbuild is no longer there to supply for
// the package excluded above.
define: {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/server": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
-29
View File
@@ -12,7 +12,6 @@ import { CreationClient } from "./creation/index.js";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import type { z } from "zod";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
@@ -5265,34 +5264,6 @@ export class DaemonClient {
});
}
async listLocalSpeechModels(requestId?: string): Promise<LocalSpeechModel[]> {
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "speech.local_models.list.request" },
responseType: "speech.local_models.list.response",
});
if (payload.error) throw new Error(payload.error);
return payload.models;
}
async downloadLocalSpeechModel(modelId: string, requestId?: string): Promise<void> {
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "speech.local_models.download.request", modelId },
responseType: "speech.local_models.download.response",
});
if (payload.error) throw new Error(payload.error);
}
async deleteLocalSpeechModel(modelId: string, requestId?: string): Promise<void> {
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "speech.local_models.delete.request", modelId },
responseType: "speech.local_models.delete.response",
});
if (payload.error) throw new Error(payload.error);
}
sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void {
this.sendSessionMessageStrict(response);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -70,8 +70,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -81,8 +81,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
-21
View File
@@ -63,16 +63,6 @@ import {
ScheduleRunOnceResponseSchema,
ScheduleUpdateResponseSchema,
} from "./schedule/rpc-schemas.js";
import {
SpeechLocalModelsDeleteRequestSchema,
SpeechLocalModelsDeleteResponseSchema,
SpeechLocalModelsDownloadRequestSchema,
SpeechLocalModelsDownloadResponseSchema,
SpeechLocalModelsListRequestSchema,
SpeechLocalModelsListResponseSchema,
SpeechSettingsPatchSchema,
SpeechSettingsSchema,
} from "./speech/rpc-schemas.js";
import {
LoopRunRequestSchema,
LoopListRequestSchema,
@@ -218,8 +208,6 @@ export const MutableDaemonConfigSchema = z
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
// COMPAT(speechSettings): added in v0.12.0, optional because older daemons omit it.
speech: SpeechSettingsSchema.optional(),
})
.passthrough();
@@ -240,7 +228,6 @@ export const MutableDaemonConfigPatchSchema = z
agentProfiles: z.array(AgentProfileSchema).optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
speech: SpeechSettingsPatchSchema.optional(),
})
.partial()
.passthrough();
@@ -3403,9 +3390,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ChatWaitRequestSchema,
ScheduleCreateRequestSchema,
ScheduleListRequestSchema,
SpeechLocalModelsListRequestSchema,
SpeechLocalModelsDownloadRequestSchema,
SpeechLocalModelsDeleteRequestSchema,
ScheduleInspectRequestSchema,
ScheduleLogsRequestSchema,
SchedulePauseRequestSchema,
@@ -3636,8 +3620,6 @@ export const ServerInfoStatusPayloadSchema = z
daemonStatusRpc: z.boolean().optional(),
// COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14.
daemonConfigReload: z.boolean().optional(),
// COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18.
speechSettings: z.boolean().optional(),
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
relayConfig: z.boolean().optional(),
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.
@@ -7079,9 +7061,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ChatWaitResponseSchema,
ScheduleCreateResponseSchema,
ScheduleListResponseSchema,
SpeechLocalModelsListResponseSchema,
SpeechLocalModelsDownloadResponseSchema,
SpeechLocalModelsDeleteResponseSchema,
ScheduleInspectResponseSchema,
ScheduleLogsResponseSchema,
SchedulePauseResponseSchema,
-165
View File
@@ -1,165 +0,0 @@
import { z } from "zod";
// ---------------------------------------------------------------------------
// Speech settings: the `speech` section of the mutable daemon config.
// ---------------------------------------------------------------------------
// Provider ids stay plain strings so an old client can parse a daemon that adds one.
const SpeechSttSettingsSchema = z
.object({
provider: z.string(),
// Omitted means the provider's default model.
model: z.string().optional(),
language: z.string().optional(),
})
.passthrough();
const SpeechTtsSettingsSchema = z
.object({
provider: z.string(),
model: z.string().optional(),
voice: z.string().optional(),
})
.passthrough();
export const SpeechSettingsSchema = z
.object({
dictation: z
.object({
enabled: z.boolean(),
stt: SpeechSttSettingsSchema,
})
.passthrough(),
voiceMode: z
.object({
enabled: z.boolean(),
stt: SpeechSttSettingsSchema,
tts: SpeechTtsSettingsSchema,
})
.passthrough(),
openai: z
.object({
// The key itself is write-only; readers only learn whether one is set.
apiKeyConfigured: z.boolean(),
baseUrl: z.string().optional(),
})
.passthrough(),
})
.passthrough();
// null clears a field back to its default. Changing `provider` without `model` clears
// the model, because model ids belong to one provider.
const SpeechSttSettingsPatchSchema = z
.object({
provider: z.string().optional(),
model: z.string().nullable().optional(),
language: z.string().nullable().optional(),
})
.passthrough();
const SpeechTtsSettingsPatchSchema = z
.object({
provider: z.string().optional(),
model: z.string().nullable().optional(),
voice: z.string().nullable().optional(),
})
.passthrough();
export const SpeechSettingsPatchSchema = z
.object({
dictation: z
.object({
enabled: z.boolean().optional(),
stt: SpeechSttSettingsPatchSchema.optional(),
})
.passthrough()
.optional(),
voiceMode: z
.object({
enabled: z.boolean().optional(),
stt: SpeechSttSettingsPatchSchema.optional(),
tts: SpeechTtsSettingsPatchSchema.optional(),
})
.passthrough()
.optional(),
openai: z
.object({
apiKey: z.string().nullable().optional(),
baseUrl: z.string().nullable().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
export type SpeechSettings = z.infer<typeof SpeechSettingsSchema>;
export type SpeechSettingsPatch = z.infer<typeof SpeechSettingsPatchSchema>;
// ---------------------------------------------------------------------------
// Local speech models: catalog, install state, download, delete.
// ---------------------------------------------------------------------------
export const LocalSpeechModelSchema = z
.object({
id: z.string(),
// "stt" or "tts".
kind: z.string(),
label: z.string(),
description: z.string(),
languages: z.array(z.string()),
downloadSizeBytes: z.number(),
isDefault: z.boolean(),
// Selected by an enabled feature; the daemon keeps it installed.
inUse: z.boolean(),
// "installed" | "not_installed" | "downloading" | "extracting" | "failed".
status: z.string(),
receivedBytes: z.number().optional(),
totalBytes: z.number().optional(),
error: z.string().optional(),
})
.passthrough();
export type LocalSpeechModel = z.infer<typeof LocalSpeechModelSchema>;
export const SpeechLocalModelsListRequestSchema = z.object({
type: z.literal("speech.local_models.list.request"),
requestId: z.string(),
});
export const SpeechLocalModelsDownloadRequestSchema = z.object({
type: z.literal("speech.local_models.download.request"),
requestId: z.string(),
modelId: z.string(),
});
export const SpeechLocalModelsDeleteRequestSchema = z.object({
type: z.literal("speech.local_models.delete.request"),
requestId: z.string(),
modelId: z.string(),
});
export const SpeechLocalModelsListResponseSchema = z.object({
type: z.literal("speech.local_models.list.response"),
payload: z.object({
requestId: z.string(),
models: z.array(LocalSpeechModelSchema),
error: z.string().nullable(),
}),
});
// Resolves once the download starts; poll the list for progress.
export const SpeechLocalModelsDownloadResponseSchema = z.object({
type: z.literal("speech.local_models.download.response"),
payload: z.object({
requestId: z.string(),
error: z.string().nullable(),
}),
});
export const SpeechLocalModelsDeleteResponseSchema = z.object({
type: z.literal("speech.local_models.delete.response"),
payload: z.object({
requestId: z.string(),
error: z.string().nullable(),
}),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/highlight": "0.12.0-beta.2",
"@getpaseo/plugin": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -285,10 +285,15 @@ describe("OMP MCP host tools", () => {
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
omp.processExit("crashed");
await omp.startTurn("again");
await vi.waitFor(() => {
expect(omp.runtimeSessions()).toHaveLength(2);
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
});
// The relaunch spawns a new runtime and MCP child; on a loaded Windows
// runner that has taken longer than vi.waitFor's default one second.
await vi.waitFor(
() => {
expect(omp.runtimeSessions()).toHaveLength(2);
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
},
{ timeout: 10_000 },
);
} finally {
await omp.close();
}
@@ -174,9 +174,6 @@ const INBOUND_PERMISSION = {
set_agent_model_request: "workspace.write",
set_agent_thinking_request: "workspace.write",
set_daemon_config_request: "daemon.manage",
"speech.local_models.list.request": "daemon.read",
"speech.local_models.download.request": "daemon.manage",
"speech.local_models.delete.request": "daemon.manage",
set_voice_mode: "workspace.write",
shutdown_server_request: "daemon.manage",
start_workspace_script_request: "workspace.write",
@@ -402,9 +399,6 @@ const OUTBOUND_PERMISSION = {
set_agent_model_response: "workspace.write",
set_agent_thinking_response: "workspace.write",
set_daemon_config_response: "daemon.manage",
"speech.local_models.list.response": "daemon.read",
"speech.local_models.download.response": "daemon.manage",
"speech.local_models.delete.response": "daemon.manage",
set_voice_mode_response: "workspace.write",
start_workspace_script_response: "workspace.write",
stash_list_response: "workspace.read",
+8 -19
View File
@@ -131,7 +131,6 @@ import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/confi
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { createSpeechService } from "./speech/speech-runtime.js";
import { createSpeechSettingsOwner } from "./speech/speech-settings.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
@@ -371,8 +370,6 @@ export interface PaseoSpeechSttLanguages {
export interface PaseoSpeechConfig {
providers: RequestedSpeechProviders;
// Where local models live, whether or not a feature currently uses the local provider.
localModelsDir: string;
sttLanguages?: PaseoSpeechSttLanguages;
local?: PaseoLocalSpeechConfig;
}
@@ -540,10 +537,6 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
return config.trustedProxies ?? ["loopback"];
}
function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv {
return config.configReload?.env ?? process.env;
}
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
const providers = config.providerOverrides ?? {};
@@ -603,24 +596,13 @@ export async function createPaseoDaemon(
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url);
const initialMutableConfig = createInitialMutableDaemonConfig(config);
const speechService = createSpeechService({
logger,
openaiConfig: config.openai,
speechConfig: config.speech,
});
logger.info({ elapsed: elapsed() }, "Speech service created");
const daemonConfigStore = new DaemonConfigStore(config.paseoHome, initialMutableConfig, logger, {
relayEnabledMutable: config.relayEnabledMutable ?? true,
startupPersisted: config.configReload?.startupPersisted,
speech: createSpeechSettingsOwner({
paseoHome: config.paseoHome,
env: resolveReloadEnv(config),
speechService,
}),
reloadSource: {
resolve: (persisted) => {
const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, {
env: resolveReloadEnv(config),
env: config.configReload?.env ?? process.env,
cli: config.configReload?.cli,
relayEnabledFallback: config.configReload?.relayEnabledFallback,
});
@@ -1609,6 +1591,13 @@ export async function createPaseoDaemon(
logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted");
}
const speechService = createSpeechService({
logger,
openaiConfig: config.openai,
speechConfig: config.speech,
});
logger.info({ elapsed: elapsed() }, "Speech service created");
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
const start = async () => {
@@ -1318,9 +1318,9 @@ test("emits disabled voice capability reasons on fresh daemon startup", async ()
expect(voice).toBeTruthy();
expect(voice?.dictation.enabled).toBe(false);
expect(voice?.dictation.reason).toBe("Dictation is off. Turn it on in Settings > Voice.");
expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config.");
expect(voice?.voice.enabled).toBe(false);
expect(voice?.voice.reason).toBe("Voice mode is off. Turn it on in Settings > Voice.");
expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config.");
} finally {
await client.close().catch(() => undefined);
await isolatedCtx.cleanup();
@@ -5,7 +5,6 @@ import { afterEach, describe, expect, test } from "vitest";
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { readSpeechSettings } from "./speech/speech-settings.js";
import type { PersistedConfig } from "./persisted-config.js";
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
@@ -1194,110 +1193,3 @@ describe("DaemonConfigStore reload", () => {
});
});
});
describe("DaemonConfigStore speech settings", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
});
function createSpeechStore(initialPersisted?: PersistedConfig) {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-speech-"));
tempDirs.push(paseoHome);
if (initialPersisted) {
writeFileSync(
path.join(paseoHome, "config.json"),
`${JSON.stringify(initialPersisted, null, 2)}\n`,
);
}
const persisted = loadPersistedConfig(paseoHome);
const applied: PersistedConfig[] = [];
const speech = {
read: (next: PersistedConfig) => readSpeechSettings(next, {}),
apply: (next: PersistedConfig) => applied.push(next),
};
const store = new DaemonConfigStore(paseoHome, reloadableConfig(persisted), undefined, {
speech,
reloadSource: {
resolve: (next) => ({ mutable: reloadableConfig(next), overrideControlledPaths: [] }),
},
});
return { paseoHome, store, applied, persisted };
}
test("keeps voice mode off until it is turned on", () => {
const { paseoHome, store } = createSpeechStore();
expect(store.get().speech?.dictation.enabled).toBe(true);
expect(store.get().speech?.voiceMode.enabled).toBe(false);
const result = store.patch({ speech: { voiceMode: { enabled: true } } });
expect(result.speech?.voiceMode.enabled).toBe(true);
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true);
});
test("stores the OpenAI key without ever returning it", () => {
const { paseoHome, store, applied } = createSpeechStore();
const result = store.patch({
speech: { dictation: { stt: { provider: "openai" } }, openai: { apiKey: " sk-test " } },
});
expect(result.speech?.openai).toEqual({ apiKeyConfigured: true });
expect(JSON.stringify(result)).not.toContain("sk-test");
expect(loadPersistedConfig(paseoHome).providers?.openai?.apiKey).toBe("sk-test");
expect(applied).toHaveLength(1);
});
test("applies a replaced key even though the visible settings do not change", () => {
const { store, applied } = createSpeechStore({
version: 1,
providers: { openai: { apiKey: "sk-old" } },
});
store.patch({ speech: { openai: { apiKey: "sk-new" } } });
expect(applied.map((next) => next.providers?.openai?.apiKey)).toEqual(["sk-new"]);
});
test("switching provider clears the previous provider's model", () => {
const { paseoHome, store } = createSpeechStore({
version: 1,
features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } },
});
const result = store.patch({ speech: { dictation: { stt: { provider: "openai" } } } });
expect(result.speech?.dictation.stt).toEqual({ provider: "openai" });
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
provider: "openai",
});
});
test("rejects a local model id outside the catalog without persisting it", () => {
const { paseoHome, store, applied } = createSpeechStore();
expect(() =>
store.patch({ speech: { voiceMode: { tts: { provider: "local", model: "nope" } } } }),
).toThrow("Unknown local voice model: nope");
expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined();
expect(applied).toEqual([]);
});
test("reload applies hand-edited speech settings without a restart", () => {
const { paseoHome, store, applied, persisted } = createSpeechStore();
writeFileSync(
path.join(paseoHome, "config.json"),
`${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`,
);
const result = store.reload();
expect(result.appliedPaths).toContain("features.voiceMode");
expect(result.restartRequiredPaths).toEqual([]);
expect(store.get().speech?.voiceMode.enabled).toBe(false);
expect(applied).toHaveLength(1);
});
});
@@ -9,8 +9,6 @@ import {
MutableDaemonConfigPatchSchema,
} from "@getpaseo/protocol/messages";
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
import type { SpeechSettings, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas";
import { mergeSpeechSettingsPatch, type SpeechSettingsOwner } from "./speech/speech-settings.js";
export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
@@ -33,11 +31,8 @@ interface SupportedMutableConfigPatch {
skills?: MutableDaemonConfig["skills"];
pluginsEnabled?: boolean;
plugins?: MutableDaemonConfig["plugins"];
speech?: SpeechSettingsPatch;
}
const SPEECH_PERSISTED_PATHS = ["features.dictation", "features.voiceMode", "providers.openai"];
interface LoggerLike {
child(bindings: Record<string, unknown>): LoggerLike;
info(...args: unknown[]): void;
@@ -281,7 +276,6 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut
...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}),
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
...(patch.speech !== undefined ? { speech: patch.speech } : {}),
};
}
@@ -324,7 +318,6 @@ export class DaemonConfigStore {
private readonly relayEnabledMutable: boolean;
private readonly reloadSource: DaemonConfigReloadSource | undefined;
private readonly startupPersisted: PersistedConfig;
private readonly speechOwner: SpeechSettingsOwner | undefined;
private lastKnownPersisted: PersistedConfig;
constructor(
@@ -335,21 +328,18 @@ export class DaemonConfigStore {
relayEnabledMutable?: boolean;
reloadSource?: DaemonConfigReloadSource;
startupPersisted?: PersistedConfig;
speech?: SpeechSettingsOwner;
} = {},
) {
this.paseoHome = paseoHome;
this.logger = getLogger(logger);
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
this.reloadSource = options.reloadSource;
this.speechOwner = options.speech;
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
this.lastKnownPersisted = this.startupPersisted;
this.current = MutableDaemonConfigSchema.parse({
...initial,
relay: initial.relay ?? { enabled: true },
...this.readSpeech(this.startupPersisted),
});
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
this.reloadSource = options.reloadSource;
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
this.lastKnownPersisted = this.startupPersisted;
}
public get(): MutableDaemonConfig {
@@ -371,11 +361,9 @@ export class DaemonConfigStore {
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
);
}
const { removeProviders = [], ...patch } = parsedPatch;
const { speech: speechPatch, ...configPatch } = patch;
const { removeProviders = [], ...configPatch } = parsedPatch;
const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch);
if (speechPatch !== undefined) merged.speech = this.readPatchedSpeech(speechPatch);
if (parsedPatch.skills?.selection !== undefined) {
merged.skills = { selection: parsedPatch.skills.selection };
}
@@ -389,47 +377,30 @@ export class DaemonConfigStore {
const configChanged = !isEqualValue(this.current, next);
// A replaced API key leaves the speech view unchanged, so a speech patch always persists.
if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) {
if (!configChanged && removedProviders.length === 0) {
return this.current;
}
const { previous: persistedBeforePatch, knownNext } = this.persistConfig(
patch,
configPatch,
removedProviders,
);
const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, knownNext);
if (configChanged) {
try {
this.applyReplacement(next, { removedProviders });
} catch (error) {
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
throw error;
}
if (!configChanged) {
this.lastKnownPersisted = knownNext;
return this.current;
}
try {
this.applyReplacement(next, { removedProviders });
this.lastKnownPersisted = knownNext;
} catch (error) {
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
throw error;
}
this.lastKnownPersisted = knownNext;
if (speechChanged) this.speechOwner?.apply(knownNext);
return this.current;
}
private readSpeech(persisted: PersistedConfig): Pick<MutableDaemonConfig, "speech"> {
return this.speechOwner ? { speech: this.speechOwner.read(persisted) } : {};
}
private readPatchedSpeech(speechPatch: SpeechSettingsPatch): SpeechSettings {
if (!this.speechOwner) {
throw new Error("Speech settings are unavailable for this daemon instance");
}
return this.speechOwner.read(mergeSpeechSettingsPatch(this.lastKnownPersisted, speechPatch));
}
private speechPersistedChanged(previous: PersistedConfig, next: PersistedConfig): boolean {
return diffPaths(previous, next).some((path) =>
SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)),
);
}
public reload(): DaemonConfigReloadResult {
if (!this.reloadSource) {
throw new Error("Daemon config reload is unavailable for this daemon instance");
@@ -442,7 +413,6 @@ export class DaemonConfigStore {
const desired = MutableDaemonConfigSchema.parse({
...resolved.mutable,
plugins: this.current.plugins,
...this.readSpeech(persisted),
});
const changedSinceLastApply = diffPaths(this.lastKnownPersisted, persisted);
const overrideControlledPaths = compactOwnedPaths(
@@ -451,7 +421,7 @@ export class DaemonConfigStore {
),
resolved.overrideControlledPaths,
);
const appliedPaths: string[] = RELOADABLE_PATHS.filter((persistedPath) => {
const appliedPaths = RELOADABLE_PATHS.filter((persistedPath) => {
if (resolved.overrideControlledPaths.some((owner) => pathBelongsTo(persistedPath, owner))) {
return false;
}
@@ -468,8 +438,6 @@ export class DaemonConfigStore {
diffPaths(this.startupPersisted, persisted).filter((path) => {
if (path === "$schema" || path === "version") return false;
if (RELOADABLE_PATHS.some((owner) => pathBelongsTo(path, owner))) return false;
if (this.speechOwner && SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)))
return false;
return !resolved.overrideControlledPaths.some((owner) => pathBelongsTo(path, owner));
}),
[],
@@ -478,17 +446,8 @@ export class DaemonConfigStore {
const removedProviders = Object.keys(this.current.providers).filter(
(provider) => !(provider in desired.providers),
);
const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, persisted);
this.applyReplacement(desired, { removedProviders });
this.lastKnownPersisted = persisted;
if (speechChanged && this.speechOwner) {
this.speechOwner.apply(persisted);
appliedPaths.push(
...SPEECH_PERSISTED_PATHS.filter((owner) =>
changedSinceLastApply.some((path) => pathBelongsTo(path, owner)),
),
);
}
return {
appliedPaths: [...appliedPaths].sort(),
@@ -625,9 +584,8 @@ function mergeMutablePatchIntoPersistedConfig(params: {
const { persisted, patch, removeProviders, persistRelayEnabled } = params;
const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled);
const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders);
const withSpeech = patch.speech ? mergeSpeechSettingsPatch(persisted, patch.speech) : persisted;
return {
...withSpeech,
...persisted,
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
...(daemon ? { daemon } : { daemon: undefined }),
@@ -0,0 +1,57 @@
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { SessionOutboundMessage } from "../messages.js";
test("deleting an agent removes it from every connected agent subscription", async () => {
const ctx = await createDaemonTestContext();
const observer = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
const updates: SessionOutboundMessage[] = [];
const requestorUpdates: SessionOutboundMessage[] = [];
try {
await observer.connect();
const agent = await ctx.client.createAgent({
provider: "codex",
cwd: ctx.daemon.staticDir,
title: "Cross-client deletion",
});
const snapshot = await observer.fetchAgents({ subscribe: {} });
expect(snapshot.entries.some((entry) => entry.agent.id === agent.id)).toBe(true);
observer.subscribeRawMessages((message) => updates.push(message));
ctx.client.subscribeRawMessages((message) => requestorUpdates.push(message));
await ctx.client.deleteAgent(agent.id);
// The record is gone, and a fresh query sees that. An existing subscriber
// must learn the same fact without reconnecting or fetching another list.
const afterDelete = await ctx.client.fetchAgents();
expect(afterDelete.entries.some((entry) => entry.agent.id === agent.id)).toBe(false);
expect(
requestorUpdates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "remove" &&
message.payload.agentId === agent.id,
),
).toBe(true);
await expect(observer.fetchAgentTimeline(agent.id, { direction: "tail" })).rejects.toThrow(
"Agent not found",
);
await expect
.poll(
() =>
updates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "remove" &&
message.payload.agentId === agent.id,
),
{ timeout: 2_000 },
)
.toBe(true);
} finally {
await observer.close();
await ctx.cleanup();
}
});
@@ -0,0 +1,161 @@
import { createServer as createHttpServer } from "node:http";
import { connect, createServer as createTcpServer, type AddressInfo, type Socket } from "node:net";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { HubRelationshipController } from "./relationship-controller.js";
import { DirectHubRelationshipRemote } from "./relationship-remote.js";
test("a silently lost Hub connection leaves connected state and reconnects when the path recovers", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-hub-liveness-"));
const hub = createHttpServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
const enrollment = JSON.parse(body) as { daemonId: string };
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
permissions: ["hub.execute"],
webSocketUrl: `ws://127.0.0.1:${proxyPort}/api/daemons/socket`,
}),
);
});
const webSockets = new WebSocketServer({ server: hub });
const peers = new Set<WebSocket>();
let connections = 0;
let pongs = 0;
webSockets.on("connection", (peer) => {
connections += 1;
peers.add(peer);
peer.on("close", () => peers.delete(peer));
});
await listen(hub);
const hubPort = (hub.address() as AddressInfo).port;
const streams = new Set<Socket>();
let dropped = false;
const proxy = createTcpServer((client) => {
const upstream = connect(hubPort, "127.0.0.1");
streams.add(client);
streams.add(upstream);
client.on("data", (data) => {
if (!dropped) upstream.write(data);
});
upstream.on("data", (data) => {
if (!dropped) client.write(data);
});
client.on("end", () => {
if (!dropped) upstream.end();
});
upstream.on("end", () => {
if (!dropped) client.end();
});
for (const socket of [client, upstream]) {
socket.on("error", () => socket.destroy());
socket.on("close", () => streams.delete(socket));
}
});
await listen(proxy);
const proxyPort = (proxy.address() as AddressInfo).port;
let retry: (() => void) | undefined;
const controller = new HubRelationshipController({
paseoHome: home,
hostname: "test-daemon",
serverId: "server-1",
daemonPublicKey: "public-key",
logger: pino({ level: "silent" }),
remote: new DirectHubRelationshipRemote(),
clock: {
now: () => new Date(),
schedule: (_delay, task) => {
retry = task;
return { cancel: () => (retry = undefined) };
},
},
attachSocket: async (socket) => {
if (!(socket instanceof WebSocket)) throw new Error("Expected a real WebSocket");
socket.on("pong", () => (pongs += 1));
},
updateAttachedPermissions: () => undefined,
createExecutionAgents: () => ({
create: async () => {
throw new Error("Unexpected agent creation");
},
subscribe: () => () => undefined,
invalidateAuthority: async () => undefined,
}),
});
try {
// Keep TCP and WebSocket I/O real; accelerate only the keepalive interval and its clock.
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
await controller.connect({
hubUrl: `http://127.0.0.1:${proxyPort}`,
token: "test-token",
permissions: ["hub.execute"],
});
await expect.poll(() => controller.status().state).toBe("connected");
const connectedAt = controller.status().connectedAt;
expect(connections).toBe(1);
// Model the reported proxy request ending without a close reaching the daemon.
dropped = true;
for (const peer of peers) peer.terminate();
await expect.poll(() => peers.size).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
await expect.poll(() => controller.status().state).toBe("reconnecting");
expect(controller.status().lastError).toBe("Hub WebSocket heartbeat timed out");
expect(retry).toBeTypeOf("function");
dropped = false;
retry?.();
await expect.poll(() => controller.status().state).toBe("connected");
expect(connections).toBe(2);
expect(controller.status().connectedAt).not.toBe(connectedAt);
expect(controller.status().lastError).toBeNull();
// A healthy idle connection survives multiple liveness windows.
for (let tick = 0; tick < 12; tick += 1) {
const before = pongs;
await vi.advanceTimersByTimeAsync(10_000);
await expect.poll(() => pongs).toBeGreaterThan(before);
}
expect(controller.status().state).toBe("connected");
expect(connections).toBe(2);
await controller.stop();
await expect.poll(() => peers.size).toBe(0);
await expect.poll(() => vi.getTimerCount()).toBe(0);
} finally {
await controller.stop();
vi.useRealTimers();
for (const socket of streams) socket.destroy();
for (const peer of peers) peer.terminate();
webSockets.close();
await Promise.all([close(proxy), close(hub)]);
await rm(home, { recursive: true, force: true });
}
});
async function listen(
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
) {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
}
async function close(
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
) {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
@@ -2,6 +2,9 @@ import { WebSocket } from "ws";
import { z } from "zod";
import type { WebSocketLike } from "../websocket-server.js";
const SOCKET_PING_INTERVAL_MS = 10_000;
const SOCKET_PONG_TIMEOUT_MS = 45_000;
export interface HubEnrollment {
daemonId: string;
idempotencyKey: string;
@@ -171,13 +174,42 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
},
});
let settled = false;
let heartbeat: ReturnType<typeof setInterval> | undefined;
const stopHeartbeat = () => {
clearInterval(heartbeat);
heartbeat = undefined;
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
stopHeartbeat();
socket.terminate();
events.failed(error);
};
let lastPongAt = 0;
socket.on("pong", () => {
lastPongAt = Date.now();
});
socket.once("upgrade", (response) => {
if (response.headers["x-paseo-session-protocol"] === "1") {
sessionProtocol = "session-v1";
}
});
socket.once("open", () => {
if (!settled) events.connected(socket as WebSocketLike, sessionProtocol);
if (settled) return;
lastPongAt = Date.now();
heartbeat = setInterval(() => {
if (socket.readyState !== WebSocket.OPEN) return;
if (Date.now() - lastPongAt >= SOCKET_PONG_TIMEOUT_MS) {
fail(new Error("Hub WebSocket heartbeat timed out"));
return;
}
socket.ping(undefined, undefined, (error) => {
if (error) fail(error);
});
}, SOCKET_PING_INTERVAL_MS);
heartbeat.unref?.();
events.connected(socket as WebSocketLike, sessionProtocol);
});
socket.once("unexpected-response", (_request, response) => {
if (settled) {
@@ -194,16 +226,12 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
events.closed(1006);
});
socket.once("close", (code) => {
stopHeartbeat();
if (settled) return;
settled = true;
events.closed(code);
});
socket.once("error", (error) => {
if (settled) return;
settled = true;
socket.terminate();
events.failed(error);
});
socket.once("error", fail);
return socket;
}

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