mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d1cea9ac0 | ||
|
|
303cebec06 | ||
|
|
fcda2f1cf0 | ||
|
|
f7a0e2cbea | ||
|
|
2a9e86b542 | ||
|
|
9ed4f1069b | ||
|
|
cc748dfe12 | ||
|
|
38a8e60780 | ||
|
|
60fa12c4de | ||
|
|
233997ed5c | ||
|
|
3882b957a6 | ||
|
|
095dd0316e | ||
|
|
a65478d0bf | ||
|
|
fc12843cef |
@@ -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
@@ -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
|
||||
|
||||
+3
-1
@@ -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,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
@@ -1 +1 @@
|
||||
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
|
||||
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
|
||||
|
||||
Generated
+28
-28
@@ -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
@@ -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\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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0-beta.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0-beta.3",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0-beta.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1333,6 +1333,18 @@ index dd70713..ca0385a 100644
|
||||
const label = language ? `Code block: ${language}` : 'Code block';
|
||||
if (callbacks.onCodeBlockPress == null) {
|
||||
return /*#__PURE__*/_jsx("pre", {
|
||||
@@ -214,7 +230,10 @@ function ImageRenderer({
|
||||
src: url,
|
||||
alt: alt,
|
||||
title: title,
|
||||
- style: imgStyle,
|
||||
+ style: {
|
||||
+ ...imgStyle,
|
||||
+ cursor: 'zoom-in'
|
||||
+ },
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
"aria-label": alt,
|
||||
diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js.map b/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js.map
|
||||
index dc917c4..cf59150 100644
|
||||
--- a/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js.map
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0-beta.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -60,30 +60,37 @@ if (!existsSync("patches") || installedPackages.length === 0) {
|
||||
|
||||
const patchFiles = readdirSync("patches").filter((file) => file.endsWith(".patch"));
|
||||
|
||||
// Group patch files by the directory patch-package must run from.
|
||||
const patchFilesByCwd = new Map();
|
||||
for (const { patchPrefix, cwd = "." } of installedPackages) {
|
||||
const isWindows = process.platform === "win32";
|
||||
const cmd = isWindows ? "patch-package.cmd" : "patch-package";
|
||||
|
||||
let failed = false;
|
||||
for (const [index, { nodeModulesPath, patchPrefix, cwd = "." }] of installedPackages.entries()) {
|
||||
const files = patchFiles.filter((file) => file.startsWith(patchPrefix));
|
||||
if (files.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const group = patchFilesByCwd.get(cwd) ?? [];
|
||||
group.push(...files);
|
||||
patchFilesByCwd.set(cwd, group);
|
||||
const outcome = applyPatches(cwd, files, index);
|
||||
if (outcome === "applied") {
|
||||
continue;
|
||||
}
|
||||
failed = true;
|
||||
if (outcome === "unavailable") {
|
||||
continue;
|
||||
}
|
||||
// patch-package cannot apply a patch that changed after an earlier version of it was
|
||||
// applied. Without the package, the next install fetches it clean and applies the patch.
|
||||
rmSync(nodeModulesPath, { recursive: true, force: true });
|
||||
console.error(
|
||||
`postinstall-patches: removed ${nodeModulesPath} because its patch did not apply. ` +
|
||||
"Run npm install again to reinstall it with the current patch. " +
|
||||
"If it fails again on the clean package, the patch is out of date and must be regenerated.",
|
||||
);
|
||||
}
|
||||
|
||||
if (patchFilesByCwd.size === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const cmd = isWindows ? "patch-package.cmd" : "patch-package";
|
||||
|
||||
let groupIndex = 0;
|
||||
for (const [cwd, files] of patchFilesByCwd) {
|
||||
groupIndex += 1;
|
||||
const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}-${groupIndex}`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
function applyPatches(cwd, files, index) {
|
||||
const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}-${index}`);
|
||||
mkdirSync(tempPatchDir, { recursive: true });
|
||||
for (const patchFile of files) {
|
||||
copyFileSync(join("patches", patchFile), join(tempPatchDir, patchFile));
|
||||
@@ -91,7 +98,7 @@ for (const [cwd, files] of patchFilesByCwd) {
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync(cmd, ["--patch-dir", relative(cwd, tempPatchDir)], {
|
||||
result = spawnSync(cmd, ["--patch-dir", relative(cwd, tempPatchDir), "--error-on-fail"], {
|
||||
cwd,
|
||||
shell: isWindows,
|
||||
stdio: "inherit",
|
||||
@@ -103,10 +110,7 @@ for (const [cwd, files] of patchFilesByCwd) {
|
||||
|
||||
if (result.error) {
|
||||
console.error("postinstall-patches: patch-package failed to spawn:", result.error.message);
|
||||
return "unavailable";
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
return result.status === 0 ? "applied" : "rejected";
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
const script = path.resolve("scripts", "postinstall-patches.mjs");
|
||||
const bin = path.resolve("node_modules", ".bin");
|
||||
const packageName = "react-native-draggable-flatlist";
|
||||
const patchFile = `${packageName}+4.0.3.patch`;
|
||||
|
||||
function createCheckout() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-"));
|
||||
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "checkout" }));
|
||||
const packageDir = path.join(root, "node_modules", packageName);
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: packageName, version: "4.0.3" }),
|
||||
);
|
||||
writeFileSync(path.join(packageDir, "index.js"), "export const value = 1;\n");
|
||||
mkdirSync(path.join(root, "patches"));
|
||||
return { root, packageDir };
|
||||
}
|
||||
|
||||
function writePatch(root, from, to) {
|
||||
const file = `node_modules/${packageName}/index.js`;
|
||||
writeFileSync(
|
||||
path.join(root, "patches", patchFile),
|
||||
[
|
||||
`diff --git a/${file} b/${file}`,
|
||||
`--- a/${file}`,
|
||||
`+++ b/${file}`,
|
||||
"@@ -1 +1 @@",
|
||||
`-export const value = ${from};`,
|
||||
`+export const value = ${to};`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function install(root, PATH = `${bin}${path.delimiter}${process.env.PATH}`) {
|
||||
return spawnSync(process.execPath, [script], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH },
|
||||
});
|
||||
}
|
||||
|
||||
test("a patch that changed since it was applied removes the package for a clean reinstall", () => {
|
||||
const { root, packageDir } = createCheckout();
|
||||
try {
|
||||
writePatch(root, 1, 2);
|
||||
assert.equal(install(root).status, 0);
|
||||
assert.equal(
|
||||
readFileSync(path.join(packageDir, "index.js"), "utf8"),
|
||||
"export const value = 2;\n",
|
||||
);
|
||||
|
||||
writePatch(root, 1, 3);
|
||||
const result = install(root);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Run npm install again/);
|
||||
assert.equal(existsSync(packageDir), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an already applied patch leaves the package in place", () => {
|
||||
const { root, packageDir } = createCheckout();
|
||||
try {
|
||||
writePatch(root, 1, 2);
|
||||
assert.equal(install(root).status, 0);
|
||||
assert.equal(install(root).status, 0);
|
||||
assert.equal(
|
||||
readFileSync(path.join(packageDir, "index.js"), "utf8"),
|
||||
"export const value = 2;\n",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a missing patch-package fails the install and leaves the package in place", () => {
|
||||
const { root, packageDir } = createCheckout();
|
||||
try {
|
||||
writePatch(root, 1, 2);
|
||||
const result = install(root, root);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /failed to spawn/);
|
||||
assert.equal(existsSync(packageDir), true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user