mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cafe4b01c8 | ||
|
|
8dd3e2e34e | ||
|
|
b8bcd204a9 | ||
|
|
d61dcaf1df | ||
|
|
644d66ba4f | ||
|
|
23e388b054 | ||
|
|
66348a5e5c | ||
|
|
b50acc9344 | ||
|
|
727d5eed59 | ||
|
|
a0790e7527 | ||
|
|
be0378ce63 | ||
|
|
f0acc2c72b | ||
|
|
559c0200e0 | ||
|
|
dc7bed417a | ||
|
|
08853039fb | ||
|
|
aef7cc36a0 | ||
|
|
7c7b6555f1 | ||
|
|
303cebec06 | ||
|
|
fcda2f1cf0 | ||
|
|
f7a0e2cbea | ||
|
|
2a9e86b542 | ||
|
|
9ed4f1069b | ||
|
|
cc748dfe12 | ||
|
|
38a8e60780 | ||
|
|
60fa12c4de | ||
|
|
233997ed5c |
@@ -93,6 +93,10 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npm run lint
|
||||
|
||||
- name: Test lint rules
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: node --test scripts/oxlint/paseo-plugin.test.mjs
|
||||
|
||||
- name: Build relay
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
run: npm run build:relay
|
||||
@@ -145,6 +149,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
|
||||
|
||||
|
||||
+112
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "error",
|
||||
@@ -356,6 +357,117 @@
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"paseo/no-raw-pressable": "error"
|
||||
}
|
||||
},
|
||||
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
|
||||
// 94 files. They may only leave this list.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/components/ui/**/*.{ts,tsx}",
|
||||
"packages/app/src/**/*.{test,spec}.{ts,tsx}",
|
||||
"packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx",
|
||||
"packages/app/src/agent-skills/selection-sheet.tsx",
|
||||
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
||||
"packages/app/src/agent-stream/view.tsx",
|
||||
"packages/app/src/app/pair-scan.tsx",
|
||||
"packages/app/src/changelog/internal/changelog-sheet.tsx",
|
||||
"packages/app/src/command-center/command-center.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-host-method-modal.tsx",
|
||||
"packages/app/src/components/add-host-modal.tsx",
|
||||
"packages/app/src/components/add-project-flow.tsx",
|
||||
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
"packages/app/src/components/attachment-pill.tsx",
|
||||
"packages/app/src/components/combined-model-selector.tsx",
|
||||
"packages/app/src/components/compact-explorer-sidebar.tsx",
|
||||
"packages/app/src/components/context-window-meter.tsx",
|
||||
"packages/app/src/components/desktop/window-controls.tsx",
|
||||
"packages/app/src/components/dictation-controls.tsx",
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/file-explorer-pane.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/highlighted-code-block.tsx",
|
||||
"packages/app/src/components/hosts/host-filter.tsx",
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
"packages/app/src/components/import-session-sheet.tsx",
|
||||
"packages/app/src/components/left-sidebar.tsx",
|
||||
"packages/app/src/components/markdown/fence/mermaid/host.native.tsx",
|
||||
"packages/app/src/components/markdown/fence/mermaid/host.web.tsx",
|
||||
"packages/app/src/components/message.tsx",
|
||||
"packages/app/src/components/model-browser.tsx",
|
||||
"packages/app/src/components/plan-card.tsx",
|
||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/question-form-card.tsx",
|
||||
"packages/app/src/components/realtime-voice-overlay.tsx",
|
||||
"packages/app/src/components/schedules/schedule-row.tsx",
|
||||
"packages/app/src/components/settings/collapsible-row.tsx",
|
||||
"packages/app/src/components/settings/headings/settings-info-tip.tsx",
|
||||
"packages/app/src/components/sidebar-callout.tsx",
|
||||
"packages/app/src/components/sidebar-resize-handle.tsx",
|
||||
"packages/app/src/components/sidebar-workspace-list.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-header-row.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-section-header.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-status-list.tsx",
|
||||
"packages/app/src/components/sidebar/workspace-meta-row/index.tsx",
|
||||
"packages/app/src/components/terminal-copy-paste-actions.tsx",
|
||||
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
|
||||
"packages/app/src/components/terminal-pane.tsx",
|
||||
"packages/app/src/components/toast-host.tsx",
|
||||
"packages/app/src/components/tool-call-sheet.tsx",
|
||||
"packages/app/src/components/welcome-screen.tsx",
|
||||
"packages/app/src/components/workspace-hover-card.tsx",
|
||||
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
|
||||
"packages/app/src/composer/agent-controls/index.tsx",
|
||||
"packages/app/src/composer/diff-stat-pill.tsx",
|
||||
"packages/app/src/composer/dock/internal/background.native.tsx",
|
||||
"packages/app/src/composer/draft/import-pill.tsx",
|
||||
"packages/app/src/composer/index.tsx",
|
||||
"packages/app/src/composer/tracks.tsx",
|
||||
"packages/app/src/desktop/browser/pane/index.electron.tsx",
|
||||
"packages/app/src/git/actions-split-button.tsx",
|
||||
"packages/app/src/git/commits-section/commit-row.tsx",
|
||||
"packages/app/src/git/commits-section/commits-section.tsx",
|
||||
"packages/app/src/git/diff-pane.tsx",
|
||||
"packages/app/src/git/forges/gitlab.view.tsx",
|
||||
"packages/app/src/git/pull-request-panel/checks-section.tsx",
|
||||
"packages/app/src/git/pull-request-panel/pane.tsx",
|
||||
"packages/app/src/git/pull-request-panel/section-kit.tsx",
|
||||
"packages/app/src/hosts/host-chooser.tsx",
|
||||
"packages/app/src/mobile-panels/presentation.tsx",
|
||||
"packages/app/src/panels/new-tab-panel.tsx",
|
||||
"packages/app/src/panels/setup-panel.tsx",
|
||||
"packages/app/src/plugins/buttons/view.tsx",
|
||||
"packages/app/src/review/surface.tsx",
|
||||
"packages/app/src/screens/open-project-screen.tsx",
|
||||
"packages/app/src/screens/project-settings-screen.tsx",
|
||||
"packages/app/src/screens/projects-screen.tsx",
|
||||
"packages/app/src/screens/settings-screen.tsx",
|
||||
"packages/app/src/screens/settings/host-appearance-section.tsx",
|
||||
"packages/app/src/screens/settings/host-page.tsx",
|
||||
"packages/app/src/screens/settings/providers-section.tsx",
|
||||
"packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx",
|
||||
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
|
||||
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
|
||||
"packages/app/src/usage/sidebar-item.tsx",
|
||||
"packages/app/src/usage/window-bar.tsx",
|
||||
"packages/app/src/workspace-labels/manager-modal.tsx",
|
||||
"packages/app/src/workspace-labels/swatch.tsx",
|
||||
"packages/app/src/workspace/open-in-editor/button.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"paseo/no-raw-pressable": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/e2e/**/fixtures.ts"],
|
||||
"rules": {
|
||||
|
||||
+29
-6
@@ -1,8 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0-beta.2 - 2026-10-17
|
||||
## 0.12.0 - 2026-10-19
|
||||
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
@@ -27,8 +27,12 @@ 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))
|
||||
- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364))
|
||||
- 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))
|
||||
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342))
|
||||
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
|
||||
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
|
||||
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
|
||||
@@ -41,6 +45,9 @@ 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 late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday))
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -54,11 +61,18 @@ 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 project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308))
|
||||
- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352))
|
||||
- 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))
|
||||
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
|
||||
- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth))
|
||||
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
|
||||
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
|
||||
@@ -68,7 +82,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))
|
||||
@@ -90,8 +104,13 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
|
||||
- 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 MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33))
|
||||
- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#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 long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353))
|
||||
- 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 the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315))
|
||||
- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336))
|
||||
- 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))
|
||||
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
@@ -102,7 +121,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,7 +166,11 @@ 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 Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344))
|
||||
- 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))
|
||||
- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber))
|
||||
- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava))
|
||||
|
||||
## 0.11.4 - 2026-10-15
|
||||
|
||||
|
||||
+2
-1
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -249,6 +249,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
|
||||
+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.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+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",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/server": "0.12.0",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37735,7 +37735,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@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",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"@getpaseo/highlight": "0.12.0",
|
||||
"@getpaseo/plugin": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"@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",
|
||||
"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",
|
||||
"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",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
closeMobileExplorer,
|
||||
@@ -205,19 +205,21 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
title: "Second touch chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
await composerLocator(page).fill("Keep this touch draft");
|
||||
await expectComposerFocused(page);
|
||||
// An external keyboard changes tabs without a pointer click blurring the
|
||||
// editor first. The device still has a touch-primary focus policy.
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expectComposerDraft(page, "");
|
||||
await switchComposerTabWithKeyboard(page, "next");
|
||||
await expectComposerDraft(page, "Keep this touch draft");
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, " after return");
|
||||
await expectComposerDraft(page, "Keep this touch draft after return");
|
||||
// Repeat after an explicit edit so retained focus from an earlier visit is
|
||||
// also dismissed when the same mounted editor becomes inactive again.
|
||||
for (const draft of ["Keep this touch draft", "Edit this touch draft again"]) {
|
||||
await composerLocator(page).fill(draft);
|
||||
await expectComposerFocused(page);
|
||||
// An external keyboard changes tabs without a pointer click blurring the
|
||||
// editor first. The device still has a touch-primary focus policy.
|
||||
await roundTripComposerTabBeforePaint(page, "Second touch chat");
|
||||
await expectComposerDraft(page, draft);
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, " after return");
|
||||
await expectComposerDraft(page, `${draft} after return`);
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
findAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openProviderCatalog(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
}
|
||||
|
||||
async function expectProviderArtwork(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
id: "devin" | "hermes" | "kiro",
|
||||
name: string,
|
||||
) {
|
||||
await findAcpCatalogProvider(page, name);
|
||||
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
|
||||
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
|
||||
await expect(icon).toBeVisible();
|
||||
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
|
||||
await expect.soft(icon).toHaveAttribute("width", "24");
|
||||
const expectedPaths = await page.evaluate((xml) => {
|
||||
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
|
||||
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
|
||||
}, ACP_PROVIDER_ICON_SVGS[id]);
|
||||
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
|
||||
for (const [index, path] of expectedPaths.entries()) {
|
||||
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) });
|
||||
}
|
||||
|
||||
test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openProviderCatalog(page);
|
||||
await expectProviderArtwork(page, testInfo, "devin", "Devin CLI");
|
||||
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
|
||||
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,14 +266,22 @@ test("keeps overview tool calls inline on desktop", async ({ page }) => {
|
||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
|
||||
|
||||
try {
|
||||
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||
await gate.waitForFirstCompleted();
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
|
||||
await group.click();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
|
||||
const header = group.getByRole("button").first();
|
||||
await header.click();
|
||||
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
|
||||
gate.release(1);
|
||||
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Prerequisite: a native terminal showing the output of echo '한글 문장'
|
||||
# on its first row, with no active selection. Use a dedicated QA workspace.
|
||||
# Coordinates target the first half of 문 on a 1080x1920 phone at default
|
||||
# code size. Override WORD_X/WORD_Y for other device geometry.
|
||||
context platform=android timeout=30000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env WORD_X=105
|
||||
env WORD_Y=251
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"Terminal input\"" 10000
|
||||
wait "label=\"한글 문장\"" 10000
|
||||
longpress "${WORD_X}" "${WORD_Y}" 650
|
||||
wait "id=\"terminal-copy\"" 5000
|
||||
press "id=\"terminal-copy\""
|
||||
# Copy clears the selection; only Android's clipboard preview has this exact
|
||||
# text, since the unselected terminal row contains the full sentence.
|
||||
wait "text=\"문장\"" 3000
|
||||
close
|
||||
@@ -0,0 +1,47 @@
|
||||
# Late composer pill clearance
|
||||
|
||||
This regression opens a completed conversation, relaunches the Android app, and
|
||||
registers a composer pill five seconds after the client plugin loads. It checks
|
||||
that the completed turn's Copy and Fork buttons remain fully above the pill.
|
||||
The daemon, plugin API, native app, layout, and accessibility bounds are real;
|
||||
no provider calls are needed during the test.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Build the server and app dependencies with `npm run build:server` and
|
||||
`npm run build --workspace=@getpaseo/expo-two-way-audio`.
|
||||
- Run an isolated development daemon on a non-default port.
|
||||
- Run this checkout's Metro server and open the connected Android dev client
|
||||
with `agent-device`. The app must already know the isolated daemon.
|
||||
- Open a completed conversation on a compact portrait Android emulator, at the
|
||||
latest turn, with no other composer tracks visible. Keep the software keyboard
|
||||
closed. Note the workspace and agent IDs from the daemon.
|
||||
|
||||
Set `WORKSPACE_ID` and `AGENT_ID` to that conversation, then run from the
|
||||
repository root, substituting the session and ports:
|
||||
|
||||
```sh
|
||||
PASEO_OVERLAY_ARTIFACTS_DIR=.dev/composer-overlay-evidence \
|
||||
node packages/app/e2e/mobile/composer-overlay/android.mjs \
|
||||
composer-overlay ws://127.0.0.1:6843/ws "$WORKSPACE_ID" "$AGENT_ID" sh.paseo.debug 8193
|
||||
```
|
||||
|
||||
The runner installs and removes its delayed plugin and leaves the conversation
|
||||
intact. It preserves the daemon's previous plugins-enabled setting. The evidence directory
|
||||
contains the cold-reopen screenshot and raw accessibility snapshot. Without an
|
||||
explicit directory, temporary evidence is removed with the fixture.
|
||||
|
||||
To check an existing capture independently:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs \
|
||||
.dev/composer-overlay-evidence/cold-reopen.json 'Overlay regression pill'
|
||||
```
|
||||
|
||||
On the failing baseline, the Copy button extends below the pill's top edge. With
|
||||
the correction, both actions are clear before the composer receives focus.
|
||||
|
||||
Additional checks on the same conversation: opening the keyboard keeps the footer
|
||||
clear; after scrolling up, a newly registered pill must not jump back to the latest
|
||||
turn. Native iOS uses a different keyboard implementation and needs its own device
|
||||
verification.
|
||||
@@ -0,0 +1,95 @@
|
||||
// Start on a completed conversation in the connected Android dev client.
|
||||
// node android.mjs <session> <daemon-ws-url> <workspace-id> <agent-id> [app-id] [metro-port]
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { WebSocket } from "ws";
|
||||
import { DaemonClient } from "../../../../client/dist/daemon-client.js";
|
||||
|
||||
const [, , session, daemonUrl, workspaceId, agentId, appId = "sh.paseo.debug", metroPort = "8081"] =
|
||||
process.argv;
|
||||
assert(
|
||||
session && daemonUrl && workspaceId && agentId,
|
||||
"Pass a session, isolated daemon URL, workspace ID, and agent ID",
|
||||
);
|
||||
const url = new URL(daemonUrl);
|
||||
assert(["127.0.0.1", "localhost"].includes(url.hostname), "Use an isolated loopback daemon");
|
||||
assert(!["", "6767", "6768"].includes(url.port), "Do not use the installed or development daemon");
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-overlay-"));
|
||||
const artifacts = process.env.PASEO_OVERLAY_ARTIFACTS_DIR ?? path.join(directory, "evidence");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const pluginId = "composer-overlay-mobile-repro";
|
||||
const pillLabel = "Overlay regression pill";
|
||||
const client = new DaemonClient({
|
||||
url: daemonUrl,
|
||||
clientId: "composer-overlay-mobile-regression",
|
||||
clientType: "cli",
|
||||
appVersion: JSON.parse(await readFile(new URL("../../../package.json", import.meta.url))).version,
|
||||
webSocketFactory: (address, options) => new WebSocket(address, options),
|
||||
});
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
let installed = false;
|
||||
let previousConfig;
|
||||
try {
|
||||
ad("wait", 'label="Copy turn"', "20000");
|
||||
await client.connect();
|
||||
previousConfig = (await client.getDaemonConfig()).config;
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: pluginId,
|
||||
requirements: { paseo: ">=0.11.4" },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`export default function contribute(client) {
|
||||
let pill;
|
||||
const timer = setTimeout(() => {
|
||||
pill = client.addComposerPill({
|
||||
id: "late-pill", workspaceId: ${JSON.stringify(workspaceId)}, agentId: ${JSON.stringify(agentId)},
|
||||
button: { title: ${JSON.stringify(pillLabel)}, icon: "Clock", behavior: { kind: "action", onPress() {} } },
|
||||
});
|
||||
}, 5000);
|
||||
return () => { clearTimeout(timer); pill?.remove(); };
|
||||
}`,
|
||||
);
|
||||
await client.installDirectoryPlugin(directory);
|
||||
installed = true;
|
||||
ad("wait", `label="${pillLabel}"`, "20000");
|
||||
// Match the report: reopen a completed conversation, then let the plugin register late.
|
||||
ad(
|
||||
"open",
|
||||
appId,
|
||||
"--platform",
|
||||
"android",
|
||||
"--metro-port",
|
||||
metroPort,
|
||||
"--no-test-ime",
|
||||
"--relaunch",
|
||||
);
|
||||
ad("wait", `label="${pillLabel}"`, "20000");
|
||||
const capture = path.join(artifacts, "cold-reopen.json");
|
||||
await writeFile(capture, ad("snapshot", "--raw", "--json"));
|
||||
ad("screenshot", path.join(artifacts, "cold-reopen.png"));
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[fileURLToPath(new URL("./assert-footer-clear.mjs", import.meta.url)), capture, pillLabel],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
} finally {
|
||||
if (installed) await client.removePlugin(pluginId);
|
||||
if (previousConfig)
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previousConfig.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Assert geometry from a fresh agent-device snapshot --raw --json capture.
|
||||
// node assert-footer-clear.mjs <snapshot.json> <composer-pill-label>
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [, , snapshotPath, pillLabel] = process.argv;
|
||||
assert(snapshotPath && pillLabel, "Pass a snapshot JSON path and composer pill label");
|
||||
const { data } = JSON.parse(readFileSync(snapshotPath, "utf8"));
|
||||
const buttons = data.nodes.filter((node) => node.type === "android.widget.Button");
|
||||
const pill = buttons.find((node) => node.label === pillLabel);
|
||||
assert(pill?.rect, `Composer pill '${pillLabel}' must be visible`);
|
||||
for (const label of ["Copy turn", "Fork chat from here"]) {
|
||||
const button = buttons.find((node) => node.label === label);
|
||||
assert(button?.rect, `Completed-turn action '${label}' must be visible`);
|
||||
const bottom = button.rect.y + button.rect.height;
|
||||
assert(
|
||||
bottom <= pill.rect.y,
|
||||
`${label} ends at ${bottom}, below composer pill top ${pill.rect.y}`,
|
||||
);
|
||||
}
|
||||
console.log("PASS: completed-turn actions remain fully above the composer pill");
|
||||
@@ -501,3 +501,59 @@ export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
|
||||
}
|
||||
|
||||
/** Exercise the quick-return race before a hidden pane gets a rendering frame. */
|
||||
export async function roundTripComposerTabBeforePaint(
|
||||
page: Page,
|
||||
activeChatTitle: string,
|
||||
): Promise<void> {
|
||||
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
|
||||
await expect(activeTab).toHaveAttribute("aria-selected", "true");
|
||||
const result = await activeTab.evaluate((tab) => {
|
||||
const editor = document.activeElement;
|
||||
return new Promise<{ returnedBeforePaint: boolean; focused: boolean }>((resolve) => {
|
||||
let switchedAway = false;
|
||||
let painted = false;
|
||||
let frame = 0;
|
||||
const shortcut = (code: "BracketLeft" | "BracketRight") => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: code === "BracketLeft" ? "{" : "}",
|
||||
code,
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
// Watch selection commits without reading layout: a layout read could make
|
||||
// Chromium blur the hidden editor and erase the condition under test.
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!switchedAway && tab.getAttribute("aria-selected") === "false") {
|
||||
switchedAway = true;
|
||||
frame = requestAnimationFrame(() => {
|
||||
painted = true;
|
||||
});
|
||||
// Deliver the return shortcut before rendering, without a Playwright
|
||||
// round trip allowing the browser to paint the inactive tab first.
|
||||
shortcut("BracketRight");
|
||||
} else if (switchedAway && tab.getAttribute("aria-selected") === "true") {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
return resolve({
|
||||
returnedBeforePaint: !painted,
|
||||
focused: document.activeElement === editor,
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
|
||||
shortcut("BracketLeft");
|
||||
});
|
||||
});
|
||||
expect(
|
||||
result.returnedBeforePaint,
|
||||
"tab round trip must finish before rendering the hidden pane",
|
||||
).toBe(true);
|
||||
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1252,6 +1252,12 @@ function agentStreamViewPropsEqual(
|
||||
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
|
||||
reasons.push("isAuthoritativeHistoryReady");
|
||||
}
|
||||
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
|
||||
reasons.push("bottomOverlayTailClearance");
|
||||
}
|
||||
if (left.bottomOverlayControlClearance !== right.bottomOverlayControlClearance) {
|
||||
reasons.push("bottomOverlayControlClearance");
|
||||
}
|
||||
if (left.toast !== right.toast) reasons.push("toast");
|
||||
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
|
||||
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M2.033 9.867l2.554 1.483a.589.589 0 00.592 0l2.554-1.483.01-.008a.608.608 0 00.11-.084l.013-.015a.631.631 0 00.076-.1c.003-.005.008-.01.01-.016a.558.558 0 00.052-.125l.007-.028a.611.611 0 00.019-.14V7.868c0-.572.307-1.105.8-1.392a1.595 1.595 0 011.598 0l1.277.742a.54.54 0 00.129.053l.028.01c.044.01.088.015.133.016h.006l.013-.002a.587.587 0 00.27-.074l.011-.004 2.554-1.483a.596.596 0 00.297-.516V2.253a.595.595 0 00-.297-.516L12.293.257a.587.587 0 00-.591 0L9.148 1.737l-.01.01a.609.609 0 00-.109.083l-.014.015a.632.632 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.028a.612.612 0 00-.018.14v1.483c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.603.603 0 00-.13-.053l-.028-.008a.658.658 0 00-.133-.018h-.02a.57.57 0 00-.269.074c-.003.002-.008.002-.012.005L2.033 5.872a.596.596 0 00-.297.515v2.966c0 .213.113.41.297.515z" fill="currentColor"></path><path d="M15.943 10.607a1.596 1.596 0 011.599 0l1.276.74c.041.025.085.04.13.055l.028.008c.043.01.088.016.133.018h.005c.005 0 .01-.002.014-.003a.474.474 0 00.122-.016l.021-.005a.616.616 0 00.126-.052c.004-.002.009-.002.013-.005l2.554-1.482a.597.597 0 00.297-.516V6.383a.596.596 0 00-.297-.515l-2.552-1.483a.587.587 0 00-.592 0l-2.553 1.482-.011.008a.61.61 0 00-.108.084l-.014.016a.637.637 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.029a.612.612 0 00-.018.14v1.482c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.584.584 0 00-.13-.053l-.028-.008a.62.62 0 00-.133-.018h-.02a.587.587 0 00-.269.074l-.012.004L9.15 10a.596.596 0 00-.296.516v2.966c0 .212.112.409.296.515l2.554 1.483s.008.002.012.005c.04.022.082.04.126.052l.02.004a.57.57 0 00.123.017l.014.002h.006c.054 0 .108-.01.16-.025a.587.587 0 00.13-.054l1.277-.741a1.597 1.597 0 012.398 1.392v1.482c0 .049.007.095.019.14l.007.028a.619.619 0 00.051.125c.004.006.008.01.01.016a.6.6 0 00.076.1l.014.015c.033.032.069.06.108.084.004.002.006.006.011.008l2.554 1.483a.59.59 0 00.593 0l2.554-1.483a.597.597 0 00.296-.516v-2.965a.595.595 0 00-.296-.516l-2.554-1.483s-.008-.002-.012-.005a.54.54 0 00-.126-.051c-.007-.003-.013-.003-.02-.005a.635.635 0 00-.125-.017h-.018a.557.557 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.595 1.595 0 01-1.598 0 1.615 1.615 0 010-2.785l-.005-.001z" fill="currentColor"></path><path d="M14.848 18.265l-2.554-1.482-.012-.005a.526.526 0 00-.126-.052c-.007-.002-.014-.002-.02-.005a.64.64 0 00-.124-.017h-.02a.56.56 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.594 1.594 0 01-1.598 0c-.493-.286-.8-.82-.8-1.393V14.65a.563.563 0 00-.018-.14l-.008-.028a.604.604 0 00-.051-.124l-.01-.017a.603.603 0 00-.076-.1l-.014-.015a.596.596 0 00-.109-.084c-.003-.002-.005-.006-.01-.008L5.178 12.65a.587.587 0 00-.591 0l-2.554 1.483a.596.596 0 00-.297.516v2.965c0 .213.113.41.297.516l2.554 1.483.012.004a.618.618 0 00.267.074l.016.002h.007a.55.55 0 00.16-.026.584.584 0 00.129-.053l1.277-.742a1.597 1.597 0 012.398 1.393v1.482c0 .05.007.095.019.14l.007.028c.013.044.03.085.051.125l.01.016c.022.036.047.07.076.1l.014.015c.032.032.069.06.109.084l.01.008 2.554 1.483a.587.587 0 00.593 0l2.554-1.483a.596.596 0 00.296-.515v-2.966a.596.596 0 00-.296-.516h-.002z" fill="currentColor"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">
|
||||
<path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>
|
||||
<path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 975 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M18.8 0H5.2A5.2 5.2 0 000 5.2v13.6A5.2 5.2 0 005.2 24h13.6a5.2 5.2 0 005.2-5.2V5.2A5.2 5.2 0 0018.8 0z" fill="#9046FF" /> <path d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001z" fill="#fff" /> <path d="M12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z" fill="#000" /></svg>
|
||||
|
After Width: | Height: | Size: 895 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="currentColor" fill-rule="nonzero"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="currentColor"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1,17 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AntigravityIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AntigravityIcon({ size = 16, color = "currentColor" }: AntigravityIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path
|
||||
transform="translate(12 12) scale(0.24) translate(-55.738 -56.945)"
|
||||
d="M89.6992 93.695C94.3659 97.195 101.366 94.8617 94.9492 88.445C75.6992 69.7783 79.7825 18.445 55.8659 18.445C31.9492 18.445 36.0325 69.7783 16.7825 88.445C9.78251 95.445 17.3658 97.195 22.0325 93.695C40.1159 81.445 38.9492 59.8617 55.8659 59.8617C72.7825 59.8617 71.6159 81.445 89.6992 93.695Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface KiroIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function KiroIcon({ size = 16, color = "currentColor" }: KiroIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001zM12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -208,6 +208,56 @@ it("lets the engine own code boundaries while classifying candidate values", asy
|
||||
]);
|
||||
});
|
||||
|
||||
describe("bare URL sentence punctuation", () => {
|
||||
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
|
||||
"leaves %s outside the link and preserves the rendered sentence",
|
||||
async (punctuation) => {
|
||||
const url = "https://github.com/getpaseo/paseo/pull/7357";
|
||||
const source = `${url}${punctuation} Not merged.`;
|
||||
const prepared = prepareMarkdownSource(source);
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
expect(tree.children?.[0]?.children).toEqual([
|
||||
{
|
||||
type: "Link",
|
||||
attributes: { url },
|
||||
children: [{ type: "Text", content: url }],
|
||||
},
|
||||
{ type: "Text", content: `${punctuation} Not merged.` },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"https://example.com/a.b",
|
||||
"https://example.com/a?foo=bar&baz=2",
|
||||
"https://example.com/a#L10",
|
||||
"https://en.wikipedia.org/wiki/Function_(mathematics)",
|
||||
"https://example.com/a_(b_(c))",
|
||||
])("preserves internal URL punctuation: %s", async (url) => {
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(`${url}.`).render());
|
||||
expect(tree.children?.[0]?.children).toEqual([
|
||||
{
|
||||
type: "Link",
|
||||
attributes: { url },
|
||||
children: [{ type: "Text", content: url }],
|
||||
},
|
||||
{ type: "Text", content: "." },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
|
||||
"preserves punctuation in explicit destinations: %s",
|
||||
async (source) => {
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render());
|
||||
expect(tree.children?.[0]?.children?.[0]?.attributes?.url).toBe("https://example.com/a.");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
|
||||
const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`";
|
||||
const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode;
|
||||
@@ -307,7 +357,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 +387,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 +414,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 +481,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);
|
||||
},
|
||||
);
|
||||
@@ -718,3 +768,10 @@ it("replaces the native placeholder only after the resolved URL decodes", () =>
|
||||
const prepared = prepareMarkdownSource("", undefined, { supportsSvg: false });
|
||||
expect(prepared.render(new Map([["shot.png", url]]))).toBe(``);
|
||||
});
|
||||
|
||||
it("makes the web Markdown container selectable in Safari as well as unprefixed browsers", () => {
|
||||
expect(createMarkdownStyles(lightTheme, { blockGap: 12 }).containerStyle).toEqual({
|
||||
userSelect: "text",
|
||||
WebkitUserSelect: "text",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,9 +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);
|
||||
const WEB_CONTAINER = { userSelect: "text" as const };
|
||||
// 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);
|
||||
|
||||
export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
text: string;
|
||||
@@ -43,21 +46,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 +88,34 @@ 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}
|
||||
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>;
|
||||
});
|
||||
|
||||
@@ -2,27 +2,31 @@ import type { MarkdownStyle } from "react-native-enriched-markdown";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
// iOS Safari needs the prefix to override the gesture host's selection suppression.
|
||||
const WEB_CONTAINER = { userSelect: "text", WebkitUserSelect: "text" } as const;
|
||||
|
||||
export function createMarkdownStyles(
|
||||
theme: Theme,
|
||||
compact = false,
|
||||
): { markdownStyle: MarkdownStyle; selectionColor: string } {
|
||||
{ compact = false, blockGap }: { compact?: boolean; blockGap: number },
|
||||
): { markdownStyle: MarkdownStyle; selectionColor: string; containerStyle?: typeof WEB_CONTAINER } {
|
||||
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 {
|
||||
containerStyle: isWeb ? WEB_CONTAINER : undefined,
|
||||
selectionColor: theme.colors.terminal.selectionBackground,
|
||||
markdownStyle: {
|
||||
paragraph,
|
||||
@@ -93,8 +97,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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ describe("resolveProviderIconName", () => {
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
||||
expect(resolveProviderIconName("devin")).toEqual({ kind: "catalog", id: "devin" });
|
||||
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
||||
expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" });
|
||||
expect(resolveProviderIconName("hermes")).toEqual({ kind: "catalog", id: "hermes" });
|
||||
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
||||
@@ -17,6 +17,16 @@ function renderIcon(Component: ProviderIconComponent) {
|
||||
}
|
||||
|
||||
describe("useProviderIcon", () => {
|
||||
it.each(["devin", "hermes", "kiro", "antigravity"])(
|
||||
"#7087: %s has a provider mark before a host snapshot arrives",
|
||||
(provider) => {
|
||||
const Icon = renderHook(() => useProviderIcon(provider)).result.current;
|
||||
expect(Icon).not.toBe(Bot);
|
||||
expect(Icon).not.toBe(PackagePlus);
|
||||
expect(typeof Icon).toBe("function");
|
||||
},
|
||||
);
|
||||
|
||||
it("renders registered snapshot SVG metadata with the requested size and color", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { providerSnapshotIcons } from "@/data/provider-icons";
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { createElement, useCallback, type ComponentType } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { TERMINAL_PROFILE_ICON_NAMES } from "@getpaseo/protocol/provider-icon-names";
|
||||
import { AntigravityIcon } from "@/components/icons/antigravity-icon";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { KiroIcon } from "@/components/icons/kiro-icon";
|
||||
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { OmpIcon } from "@/components/icons/omp-icon";
|
||||
@@ -23,10 +25,11 @@ export interface ProviderIconProps {
|
||||
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
|
||||
|
||||
const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||
antigravity: AntigravityIcon as unknown as ProviderIconComponent,
|
||||
claude: ClaudeIcon as unknown as ProviderIconComponent,
|
||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||
kiro: PackagePlus,
|
||||
kiro: KiroIcon as unknown as ProviderIconComponent,
|
||||
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
|
||||
omp: OmpIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -868,7 +868,9 @@ function NativeTerminalEmulator({
|
||||
panState.movedBeyondTapTolerance = true;
|
||||
}
|
||||
if (selectionPress?.status === "selecting") {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
if (panState.movedBeyondTapTolerance) {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -915,7 +917,10 @@ function NativeTerminalEmulator({
|
||||
onPanResponderRelease: (event, gesture) => {
|
||||
clearSelectionLongPressTimeout();
|
||||
const selectionPress = selectionPressRef.current;
|
||||
if (selectionPress?.status === "selecting") {
|
||||
if (
|
||||
selectionPress?.status === "selecting" &&
|
||||
panStateRef.current.movedBeyondTapTolerance
|
||||
) {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
}
|
||||
selectionPressRef.current = null;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1295,7 +1295,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
// Read pointer capability when restoring, without making a pointer change itself
|
||||
// a focus trigger.
|
||||
useLayoutEffect(() => {
|
||||
if (isPaneFocused && focusIntentRef.current && hasFinePointer()) {
|
||||
const finePointer = hasFinePointer();
|
||||
if (!isPaneFocused && isWeb && !finePointer) {
|
||||
// Hiding a retained tab only blurs its editor after the browser renders.
|
||||
// A quick return before that frame would otherwise keep touch focus.
|
||||
textInputRef.current?.blur();
|
||||
} else if (isPaneFocused && focusIntentRef.current && finePointer) {
|
||||
textInputRef.current?.focus();
|
||||
}
|
||||
}, [isPaneFocused]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
beginMessageSubmission,
|
||||
getActiveMessageSubmissions,
|
||||
getSendingClientMessageIds,
|
||||
hasActiveMessageSubmission,
|
||||
observeMessageSubmissionCanonical,
|
||||
rejectMessageSubmission,
|
||||
} from "./model";
|
||||
@@ -53,6 +54,21 @@ describe("message submission transactions", () => {
|
||||
expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports activity until every submission is provider acknowledged", () => {
|
||||
const both = beginMessageSubmission(
|
||||
beginMessageSubmission([], { clientMessageId: "client-1" }),
|
||||
{ clientMessageId: "client-2" },
|
||||
);
|
||||
const oneAcknowledged = observeMessageSubmissionCanonical(both, ["client-1"]);
|
||||
|
||||
expect(hasActiveMessageSubmission(undefined)).toBe(false);
|
||||
expect(hasActiveMessageSubmission(both)).toBe(true);
|
||||
expect(hasActiveMessageSubmission(oneAcknowledged)).toBe(true);
|
||||
expect(
|
||||
hasActiveMessageSubmission(observeMessageSubmissionCanonical(oneAcknowledged, ["client-2"])),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("records provider acknowledgement without settling another transaction", () => {
|
||||
const both = beginMessageSubmission(
|
||||
beginMessageSubmission([], { clientMessageId: "client-1" }),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState, type ReactElement, type ReactNode } fro
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
MENU_ITEM_HEIGHT,
|
||||
MenuRoot,
|
||||
MenuSeparator,
|
||||
MenuSurface,
|
||||
@@ -312,6 +313,22 @@ const styles = StyleSheet.create((theme) => {
|
||||
backgroundColor: getStatusDotColor({ theme, bucket, showDoneAsInactive: true }) ?? undefined,
|
||||
});
|
||||
|
||||
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
|
||||
// inside it, and tall enough that revealing an action button cannot resize the row.
|
||||
//
|
||||
// Compact and desktop differ here and only here. A finger needs the taller row a menu row gets
|
||||
// on compact; a pointer does not, and the popover keeps its density.
|
||||
const trackRow = {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 },
|
||||
marginHorizontal: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] },
|
||||
borderRadius: theme.borderRadius.md,
|
||||
} as const;
|
||||
|
||||
return {
|
||||
bar: {
|
||||
position: "absolute",
|
||||
@@ -332,29 +349,8 @@ const styles = StyleSheet.create((theme) => {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
|
||||
// inside it, and tall enough that revealing an action button cannot resize the row.
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 32,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
rowActive: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 32,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
row: trackRow,
|
||||
rowActive: { ...trackRow, backgroundColor: theme.colors.surface2 },
|
||||
// Segments sit twice as far apart as a mark sits from its own text, so a count reads as
|
||||
// belonging to the mark on its left rather than to the words on its right.
|
||||
segments: {
|
||||
|
||||
@@ -133,7 +133,7 @@ const CATALOG_DATA = [
|
||||
title: "Devin CLI",
|
||||
description: "Cognition's Devin for Terminal via Agent Client Protocol",
|
||||
version: "manual",
|
||||
iconId: null,
|
||||
iconId: "devin",
|
||||
installLink: "https://cli.devin.ai/docs",
|
||||
command: ["devin", "acp"],
|
||||
},
|
||||
@@ -235,7 +235,7 @@ const CATALOG_DATA = [
|
||||
title: "Hermes",
|
||||
description: "Nous Research self-improving AI agent",
|
||||
version: "manual",
|
||||
iconId: null,
|
||||
iconId: "hermes",
|
||||
installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
command: ["hermes", "acp"],
|
||||
},
|
||||
@@ -272,7 +272,7 @@ const CATALOG_DATA = [
|
||||
title: "Kiro CLI",
|
||||
description: "Amazon's AI coding agent with native ACP support",
|
||||
version: "manual",
|
||||
iconId: null,
|
||||
iconId: "kiro",
|
||||
installLink: "https://kiro.dev/docs/cli/acp/",
|
||||
command: ["kiro-cli", "acp"],
|
||||
},
|
||||
|
||||
@@ -11,6 +11,13 @@ function findProvider(id: string) {
|
||||
}
|
||||
|
||||
describe("ACP provider catalog", () => {
|
||||
it.each(["devin", "hermes", "kiro"])(
|
||||
"#7087: %s supplies its provider mark to the catalog picker",
|
||||
(provider) => {
|
||||
expect(findProvider(provider).iconSvg).toContain("<svg");
|
||||
},
|
||||
);
|
||||
|
||||
it("vendors provider entries with unique ids and concrete commands", () => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ import { useWorkspaceRecovery } from "@/workspace-recovery/use-workspace-recover
|
||||
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
createWorkspaceAgentVisibilitySelector,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
@@ -1148,14 +1148,17 @@ function WorkspaceScreenContent({
|
||||
),
|
||||
});
|
||||
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
|
||||
const selectWorkspaceAgentVisibility = useMemo(
|
||||
() =>
|
||||
createWorkspaceAgentVisibilitySelector({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
selectWorkspaceAgentVisibility,
|
||||
workspaceAgentVisibilityEqual,
|
||||
);
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select";
|
||||
import {
|
||||
createSubagentsForParentSelector,
|
||||
selectProviderSubagentsForParent,
|
||||
selectSubagentsForParent,
|
||||
} from "./select";
|
||||
import { createWorkspaceAgentVisibilitySelector } from "@/workspace-tabs/agent-visibility";
|
||||
import { useProviderSubagentStore } from "./provider-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
|
||||
@@ -435,3 +440,160 @@ describe("selectSubagentsForParent", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSubagentsForParentSelector", () => {
|
||||
class IterationCountingMap<K, V> extends Map<K, V> {
|
||||
iterations = 0;
|
||||
override values() {
|
||||
this.iterations += 1;
|
||||
return super.values();
|
||||
}
|
||||
override entries() {
|
||||
this.iterations += 1;
|
||||
return super.entries();
|
||||
}
|
||||
override [Symbol.iterator]() {
|
||||
this.iterations += 1;
|
||||
return super[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
|
||||
const params = { serverId: SERVER_ID, parentAgentId: "parent" };
|
||||
|
||||
function streamTick(text: string): void {
|
||||
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", {
|
||||
head: [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "message-1",
|
||||
text,
|
||||
timestamp: new Date("2026-03-08T10:05:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("returns the cached rows without walking agents when a stream tick leaves them unchanged", () => {
|
||||
const agents = new IterationCountingMap<string, Agent>(
|
||||
[
|
||||
makeAgent({ id: "parent" }),
|
||||
makeAgent({ id: "child", parentAgentId: "parent" }),
|
||||
makeAgent({ id: "unrelated" }),
|
||||
].map((agent) => [agent.id, agent]),
|
||||
);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents(SERVER_ID, agents);
|
||||
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
|
||||
const before = select(useSessionStore.getState());
|
||||
const iterationsAfterFirstSelect = agents.iterations;
|
||||
|
||||
for (let tick = 0; tick < 100; tick += 1) {
|
||||
streamTick(`token ${tick}`);
|
||||
expect(select(useSessionStore.getState())).toBe(before);
|
||||
}
|
||||
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
|
||||
expect(agents.iterations).toBe(iterationsAfterFirstSelect);
|
||||
expect(before.map((row) => row.id)).toEqual(["child"]);
|
||||
});
|
||||
|
||||
it("recomputes when the agents map changes", () => {
|
||||
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child-a", parentAgentId: "parent" })]);
|
||||
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
|
||||
const before = select(useSessionStore.getState());
|
||||
|
||||
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
|
||||
const child = makeAgent({
|
||||
id: "child-b",
|
||||
parentAgentId: "parent",
|
||||
createdAt: new Date("2026-03-08T10:01:00.000Z"),
|
||||
});
|
||||
return new Map(current).set(child.id, child);
|
||||
});
|
||||
const after = select(useSessionStore.getState());
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.map((row) => row.id)).toEqual(["child-a", "child-b"]);
|
||||
});
|
||||
|
||||
it("keeps the previous rows when a changed agents map yields the same rows", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent" }),
|
||||
makeAgent({ id: "child", parentAgentId: "parent" }),
|
||||
makeAgent({ id: "unrelated" }),
|
||||
]);
|
||||
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
|
||||
const before = select(useSessionStore.getState());
|
||||
|
||||
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
|
||||
return new Map(current).set("unrelated", makeAgent({ id: "unrelated", status: "running" }));
|
||||
});
|
||||
|
||||
expect(select(useSessionStore.getState())).toBe(before);
|
||||
});
|
||||
|
||||
it("applies the pending archive ids it was created with", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent" }),
|
||||
makeAgent({ id: "child-a", parentAgentId: "parent" }),
|
||||
makeAgent({ id: "child-b", parentAgentId: "parent" }),
|
||||
]);
|
||||
|
||||
const rows = createSubagentsForParentSelector(
|
||||
params,
|
||||
new Set(["child-b"]),
|
||||
)(useSessionStore.getState());
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
||||
});
|
||||
it("does no agent map walks for 100 stream commits with unchanged source maps", () => {
|
||||
const agents = new IterationCountingMap<string, Agent>(
|
||||
Array.from({ length: 200 }, (_, i) => {
|
||||
const agent = makeAgent({
|
||||
id: `agent-${i}`,
|
||||
workspaceId: "workspace",
|
||||
parentAgentId: i < 3 ? null : `agent-${i % 3}`,
|
||||
});
|
||||
return [agent.id, agent] as const;
|
||||
}),
|
||||
);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents(SERVER_ID, agents);
|
||||
const selectVisibility = createWorkspaceAgentVisibilitySelector({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: "workspace",
|
||||
});
|
||||
const selectChildren = Array.from({ length: 3 }, (_, i) =>
|
||||
createSubagentsForParentSelector(
|
||||
{ serverId: SERVER_ID, parentAgentId: `agent-${i}` },
|
||||
EMPTY_PENDING_ARCHIVE_IDS,
|
||||
),
|
||||
);
|
||||
const select = (state: ReturnType<typeof useSessionStore.getState>) => {
|
||||
selectVisibility(state);
|
||||
selectChildren.forEach((selector) => selector(state));
|
||||
};
|
||||
select(useSessionStore.getState());
|
||||
const before = agents.iterations;
|
||||
const unsubscribe = useSessionStore.subscribe(select);
|
||||
try {
|
||||
for (let tick = 0; tick < 100; tick++) {
|
||||
useSessionStore.getState().setAgentStreamState(SERVER_ID, "agent-0", {
|
||||
head: [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "message",
|
||||
text: `token ${tick}`,
|
||||
timestamp: AGENT_TIMESTAMP,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
|
||||
expect(agents.iterations - before).toBe(0);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,31 @@ export function selectSubagentsForParent(
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Session-store selectors run on every store update, including every stream tick, which
|
||||
// leaves the agent map untouched. Reuse the previous rows until the map changes, and keep
|
||||
// them when a recompute yields equal rows so the hook's equality check stays O(1).
|
||||
export function createSubagentsForParentSelector(
|
||||
params: SelectSubagentsParams,
|
||||
pendingArchiveIds: ReadonlySet<string>,
|
||||
): (state: SessionStoreSnapshot) => SubagentRow[] {
|
||||
let previousAgents: Map<string, Agent> | undefined;
|
||||
let previousRows: SubagentRow[] | null = null;
|
||||
|
||||
return (state) => {
|
||||
const agents = state.sessions[params.serverId]?.agents;
|
||||
if (previousRows && agents === previousAgents) {
|
||||
return previousRows;
|
||||
}
|
||||
|
||||
const rows = selectSubagentsForParent(state, params, pendingArchiveIds);
|
||||
previousAgents = agents;
|
||||
if (!previousRows || !equal(previousRows, rows)) {
|
||||
previousRows = rows;
|
||||
}
|
||||
return previousRows;
|
||||
};
|
||||
}
|
||||
|
||||
export function selectProviderSubagentsForParent(
|
||||
state: ProviderSubagentStoreSnapshot,
|
||||
params: SelectSubagentsParams,
|
||||
@@ -133,12 +158,13 @@ export function selectProviderSubagentsForParent(
|
||||
}
|
||||
|
||||
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
|
||||
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
|
||||
const paseoRows = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
|
||||
equal,
|
||||
const { serverId, parentAgentId } = params;
|
||||
const pendingArchiveIds = usePendingArchiveAgentIds(serverId);
|
||||
const selectPaseoRows = useMemo(
|
||||
() => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds),
|
||||
[serverId, parentAgentId, pendingArchiveIds],
|
||||
);
|
||||
const paseoRows = useStoreWithEqualityFn(useSessionStore, selectPaseoRows, equal);
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, type ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, Unlink } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { ComposerTrackActions, ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
@@ -49,7 +50,6 @@ export interface SubagentsTrackProps {
|
||||
|
||||
const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
|
||||
|
||||
/** Leading and action glyphs share one size so rows keep a single icon column. */
|
||||
const ROW_ICON_SIZE = 14;
|
||||
|
||||
function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation {
|
||||
@@ -234,6 +234,7 @@ function SubagentsTrackRow({
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
compact={isCompact}
|
||||
visible={actionsAlwaysVisible || active}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
@@ -246,6 +247,7 @@ function SubagentsTrackRow({
|
||||
displayLabel,
|
||||
handleArchivePress,
|
||||
handleDetachPress,
|
||||
isCompact,
|
||||
onDetachSubagent,
|
||||
presentation,
|
||||
row.kind,
|
||||
@@ -284,17 +286,20 @@ function SubagentFacts({ rowId, text }: { rowId: string; text: string | null })
|
||||
function SubagentRowActions({
|
||||
rowId,
|
||||
displayLabel,
|
||||
compact,
|
||||
visible,
|
||||
onDetachPress,
|
||||
onArchivePress,
|
||||
}: {
|
||||
rowId: string;
|
||||
displayLabel: string;
|
||||
compact: boolean;
|
||||
visible: boolean;
|
||||
onDetachPress?: () => void;
|
||||
onArchivePress: () => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
return (
|
||||
<View
|
||||
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
|
||||
@@ -302,69 +307,59 @@ function SubagentRowActions({
|
||||
>
|
||||
{onDetachPress ? (
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
|
||||
testID={`subagents-track-detach-${rowId}`}
|
||||
label={t("subagents.detachAction", { label: displayLabel })}
|
||||
tooltipLabel={t("subagents.detachTooltip")}
|
||||
icon="detach"
|
||||
visible={visible}
|
||||
compact={compact}
|
||||
onPress={onDetachPress}
|
||||
/>
|
||||
testID={`subagents-track-detach-${rowId}`}
|
||||
>
|
||||
<ThemedUnlink size={iconSize} uniProps={mutedIconColorMapping} />
|
||||
</SubagentActionButton>
|
||||
) : null}
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
||||
testID={`subagents-track-archive-${rowId}`}
|
||||
label={t("subagents.archiveAction", { label: displayLabel })}
|
||||
tooltipLabel={t("subagents.archiveTooltip")}
|
||||
icon="archive"
|
||||
visible={visible}
|
||||
compact={compact}
|
||||
onPress={onArchivePress}
|
||||
/>
|
||||
testID={`subagents-track-archive-${rowId}`}
|
||||
>
|
||||
<ThemedArchive size={iconSize} uniProps={mutedIconColorMapping} />
|
||||
</SubagentActionButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type SubagentActionIcon = "archive" | "detach";
|
||||
|
||||
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
|
||||
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
if (icon === "detach") {
|
||||
return <ThemedUnlink size={ROW_ICON_SIZE} uniProps={uniProps} />;
|
||||
}
|
||||
return <ThemedArchive size={ROW_ICON_SIZE} uniProps={uniProps} />;
|
||||
}
|
||||
|
||||
/** The standard icon action, with a tooltip that names the action without repeating the row. */
|
||||
function SubagentActionButton({
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
label,
|
||||
tooltipLabel,
|
||||
icon,
|
||||
visible,
|
||||
compact,
|
||||
onPress,
|
||||
testID,
|
||||
children,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
label: string;
|
||||
tooltipLabel: string;
|
||||
icon: SubagentActionIcon;
|
||||
visible: boolean;
|
||||
compact: boolean;
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const tooltip = useMemo(
|
||||
() => <Text style={styles.tooltipText}>{tooltipLabel}</Text>,
|
||||
[tooltipLabel],
|
||||
);
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
style={styles.actionButton}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
tooltipSide="top"
|
||||
compact={compact}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -415,11 +410,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
opacity: 0,
|
||||
},
|
||||
actionButton: {
|
||||
padding: theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -151,6 +151,61 @@ describe("native terminal selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ col: 0, expected: "한글", startCol: 0, endCol: 3 },
|
||||
{ col: 1, expected: "한글", startCol: 0, endCol: 3 },
|
||||
{ col: 2, expected: "한글", startCol: 0, endCol: 3 },
|
||||
{ col: 3, expected: "한글", startCol: 0, endCol: 3 },
|
||||
{ col: 5, expected: "문장", startCol: 5, endCol: 8 },
|
||||
{ col: 6, expected: "문장", startCol: 5, endCol: 8 },
|
||||
{ col: 7, expected: "문장", startCol: 5, endCol: 8 },
|
||||
{ col: 8, expected: "문장", startCol: 5, endCol: 8 },
|
||||
])(
|
||||
"selects the whole Korean word from column $col, including continuation cells",
|
||||
async ({ col, expected, startCol, endCol }) => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
|
||||
try {
|
||||
await terminal.write("한글 문장");
|
||||
const bounds = terminal.getBufferBounds();
|
||||
const row = bounds.cursorRow;
|
||||
const selection = resolveTerminalWordSelection({ terminal, coordinate: { row, col } });
|
||||
|
||||
expect({ selection, text: extractTerminalSelectedText({ terminal, selection }) }).toEqual({
|
||||
selection: {
|
||||
start: { row, col: startCol },
|
||||
end: { row, col: endCol },
|
||||
coordinateEpoch: bounds.coordinateEpoch,
|
||||
},
|
||||
text: expected,
|
||||
});
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ text: "한글 문장", col: 4, expected: "" },
|
||||
{ text: "(한글)", col: 2, expected: "한글" },
|
||||
{ text: "漢字 test", col: 1, expected: "漢字" },
|
||||
{ text: "a한b x", col: 2, expected: "a한b" },
|
||||
{ text: "🙂🙂 ok", col: 1, expected: "🙂🙂" },
|
||||
{ text: "e\u0301한 ok", col: 2, expected: "e\u0301한" },
|
||||
{ text: "한글", col: 3, expected: "한글" },
|
||||
])("preserves word boundaries in $text at column $col", async ({ text, col, expected }) => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
|
||||
try {
|
||||
await terminal.write(text);
|
||||
const selection = resolveTerminalWordSelection({
|
||||
terminal,
|
||||
coordinate: { row: terminal.getBufferBounds().cursorRow, col },
|
||||
});
|
||||
expect(extractTerminalSelectedText({ terminal, selection })).toBe(expected);
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps screen coordinates through the current visible window", () => {
|
||||
expect(
|
||||
hitTestTerminalSelectionCell({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NativeHeadlessTerminal } from "./headless-terminal-state";
|
||||
import type { NativeHeadlessTerminal, NativeTerminalCell } from "./headless-terminal-state";
|
||||
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
|
||||
|
||||
export interface TerminalBufferCoordinate {
|
||||
@@ -101,6 +101,10 @@ interface TerminalSelectionState {
|
||||
|
||||
const TERMINAL_WORD_SEPARATORS = " ()[]{}',\"`";
|
||||
|
||||
function isTerminalWordSeparator(cell: NativeTerminalCell): boolean {
|
||||
return cell.width !== 0 && TERMINAL_WORD_SEPARATORS.includes(cell.char || " ");
|
||||
}
|
||||
|
||||
function compareCoordinates(
|
||||
left: TerminalBufferCoordinate,
|
||||
right: TerminalBufferCoordinate,
|
||||
@@ -183,8 +187,7 @@ export function resolveTerminalWordSelection(
|
||||
if (!cells || input.coordinate.col < 0 || input.coordinate.col >= cells.length) {
|
||||
return null;
|
||||
}
|
||||
const target = cells[input.coordinate.col]?.char || " ";
|
||||
if (TERMINAL_WORD_SEPARATORS.includes(target)) {
|
||||
if (isTerminalWordSeparator(cells[input.coordinate.col])) {
|
||||
return {
|
||||
start: input.coordinate,
|
||||
end: input.coordinate,
|
||||
@@ -195,13 +198,11 @@ export function resolveTerminalWordSelection(
|
||||
let startCol = input.coordinate.col;
|
||||
let endCol = input.coordinate.col;
|
||||
while (startCol > 0) {
|
||||
const previous = cells[startCol - 1]?.char || " ";
|
||||
if (TERMINAL_WORD_SEPARATORS.includes(previous)) break;
|
||||
if (isTerminalWordSeparator(cells[startCol - 1])) break;
|
||||
startCol -= 1;
|
||||
}
|
||||
while (endCol + 1 < cells.length) {
|
||||
const next = cells[endCol + 1]?.char || " ";
|
||||
if (TERMINAL_WORD_SEPARATORS.includes(next)) break;
|
||||
if (isTerminalWordSeparator(cells[endCol + 1])) break;
|
||||
endCol += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
createWorkspaceAgentVisibilitySelector,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
shouldPruneWorkspaceAgentTab,
|
||||
workspaceAgentVisibilityEqual,
|
||||
@@ -336,6 +338,139 @@ describe("workspace agent visibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorkspaceAgentVisibilitySelector", () => {
|
||||
const SERVER_ID = "srv";
|
||||
|
||||
class IterationCountingMap<K, V> extends Map<K, V> {
|
||||
iterations = 0;
|
||||
override values() {
|
||||
this.iterations += 1;
|
||||
return super.values();
|
||||
}
|
||||
override entries() {
|
||||
this.iterations += 1;
|
||||
return super.entries();
|
||||
}
|
||||
override [Symbol.iterator]() {
|
||||
this.iterations += 1;
|
||||
return super[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
|
||||
function seedSession(agents: Map<string, Agent>): void {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents(SERVER_ID, agents);
|
||||
}
|
||||
|
||||
function streamTick(text: string): void {
|
||||
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent-agent", {
|
||||
head: [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "message-1",
|
||||
text,
|
||||
timestamp: new Date("2026-03-04T00:02:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const parent = makeAgent({ id: "parent-agent", cwd: "/repo", workspaceId: WORKSPACE_ID });
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
cwd: "/repo",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
parentAgentId: parent.id,
|
||||
});
|
||||
const otherWorkspace = makeAgent({ id: "other-agent", cwd: "/other", workspaceId: "ws-2" });
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
});
|
||||
|
||||
it("returns the cached result without walking agents when a stream tick leaves them unchanged", () => {
|
||||
const agents = new IterationCountingMap<string, Agent>([
|
||||
[parent.id, parent],
|
||||
[otherWorkspace.id, otherWorkspace],
|
||||
]);
|
||||
seedSession(agents);
|
||||
const select = createWorkspaceAgentVisibilitySelector({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
const before = select(useSessionStore.getState());
|
||||
const iterationsAfterFirstSelect = agents.iterations;
|
||||
|
||||
for (let tick = 0; tick < 100; tick += 1) {
|
||||
streamTick(`token ${tick}`);
|
||||
expect(select(useSessionStore.getState())).toBe(before);
|
||||
}
|
||||
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
|
||||
expect(agents.iterations).toBe(iterationsAfterFirstSelect);
|
||||
expect(before.activeAgentIds).toEqual(new Set([parent.id]));
|
||||
});
|
||||
|
||||
it("recomputes when the agents map changes", () => {
|
||||
const agents = new Map([[parent.id, parent]]);
|
||||
seedSession(agents);
|
||||
const select = createWorkspaceAgentVisibilitySelector({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
const before = select(useSessionStore.getState());
|
||||
|
||||
useSessionStore.getState().setAgents(SERVER_ID, new Map(agents).set(child.id, child));
|
||||
const after = select(useSessionStore.getState());
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.activeAgentIds).toEqual(new Set([parent.id, child.id]));
|
||||
expect(after.autoOpenAgentIds).toEqual(new Set([parent.id]));
|
||||
});
|
||||
|
||||
it("keeps the previous result when a changed agents map yields the same sets", () => {
|
||||
const agents = new Map([
|
||||
[parent.id, parent],
|
||||
[otherWorkspace.id, otherWorkspace],
|
||||
]);
|
||||
seedSession(agents);
|
||||
const select = createWorkspaceAgentVisibilitySelector({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
const before = select(useSessionStore.getState());
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgents(
|
||||
SERVER_ID,
|
||||
new Map(agents).set(otherWorkspace.id, { ...otherWorkspace, title: "Renamed" }),
|
||||
);
|
||||
|
||||
expect(select(useSessionStore.getState())).toBe(before);
|
||||
});
|
||||
|
||||
it("recomputes when agent details reveal a parent in another workspace", () => {
|
||||
seedSession(new Map([[child.id, child]]));
|
||||
const select = createWorkspaceAgentVisibilitySelector({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
expect(select(useSessionStore.getState()).autoOpenAgentIds).toEqual(new Set<string>());
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentDetails(
|
||||
SERVER_ID,
|
||||
new Map([[parent.id, { ...parent, workspaceId: "ws-parent" }]]),
|
||||
);
|
||||
const after = select(useSessionStore.getState());
|
||||
|
||||
expect(after.activeAgentIds).toEqual(new Set([child.id]));
|
||||
expect(after.autoOpenAgentIds).toEqual(new Set([child.id]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceAgentVisibilityEqual", () => {
|
||||
it("returns true for identical sets", () => {
|
||||
const a = {
|
||||
|
||||
@@ -28,17 +28,15 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
|
||||
const activeAgentIds = new Set<string>();
|
||||
const autoOpenAgentIds = new Set<string>();
|
||||
const agentsById = new Map<string, Agent>([
|
||||
...(agentDetails?.entries() ?? []),
|
||||
...(sessionAgents?.entries() ?? []),
|
||||
]);
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
if (!agentBelongsToWorkspace(agent, workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
if (!agent.archivedAt) {
|
||||
activeAgentIds.add(agent.id);
|
||||
const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined;
|
||||
const parentAgent = agent.parentAgentId
|
||||
? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId))
|
||||
: undefined;
|
||||
if (isWorkspaceRootAgent(agent, parentAgent)) {
|
||||
autoOpenAgentIds.add(agent.id);
|
||||
}
|
||||
@@ -47,6 +45,46 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
return { activeAgentIds, autoOpenAgentIds };
|
||||
}
|
||||
|
||||
interface AgentMapsSnapshot {
|
||||
sessions: Record<
|
||||
string,
|
||||
{ agents: Map<string, Agent>; agentDetails: Map<string, Agent> } | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
// Session-store selectors run on every store update, including every stream tick, which
|
||||
// leaves the agent maps untouched. Reuse the previous result until either map changes, and
|
||||
// keep it when a recompute yields the same sets so the hook's equality check stays O(1).
|
||||
export function createWorkspaceAgentVisibilitySelector(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | null | undefined;
|
||||
}): (state: AgentMapsSnapshot) => WorkspaceAgentVisibility {
|
||||
let previousAgents: Map<string, Agent> | undefined;
|
||||
let previousAgentDetails: Map<string, Agent> | undefined;
|
||||
let previousVisibility: WorkspaceAgentVisibility | null = null;
|
||||
|
||||
return (state) => {
|
||||
const session = state.sessions[input.serverId];
|
||||
const agents = session?.agents;
|
||||
const agentDetails = session?.agentDetails;
|
||||
if (previousVisibility && agents === previousAgents && agentDetails === previousAgentDetails) {
|
||||
return previousVisibility;
|
||||
}
|
||||
|
||||
const visibility = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: agents,
|
||||
agentDetails,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
previousAgents = agents;
|
||||
previousAgentDetails = agentDetails;
|
||||
if (!previousVisibility || !workspaceAgentVisibilityEqual(previousVisibility, visibility)) {
|
||||
previousVisibility = visibility;
|
||||
}
|
||||
return previousVisibility;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabSnapshot(input: {
|
||||
agentVisibility: WorkspaceAgentVisibility;
|
||||
agentsHydrated: boolean;
|
||||
@@ -73,8 +111,9 @@ export function workspaceAgentVisibilityEqual(
|
||||
b: WorkspaceAgentVisibility,
|
||||
): boolean {
|
||||
return (
|
||||
setsEqual(a.activeAgentIds, b.activeAgentIds) &&
|
||||
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)
|
||||
a === b ||
|
||||
(setsEqual(a.activeAgentIds, b.activeAgentIds) &&
|
||||
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0",
|
||||
"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",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/server": "0.12.0",
|
||||
"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",
|
||||
"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",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0",
|
||||
"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",
|
||||
"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",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0",
|
||||
"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",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@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",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"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",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// COMPAT(paseoToolCallDetail): added in v0.12.0, remove after 2027-04-19 once plugins have migrated to paseo-tool-presentation/index.
|
||||
export {
|
||||
buildPaseoToolDetailSections,
|
||||
type PaseoToolDetailField,
|
||||
type PaseoToolDetailSection,
|
||||
} from "./paseo-tool-presentation/index.js";
|
||||
@@ -1,3 +1,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { copyFileSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildPaseoToolDetailSections } from "./index.js";
|
||||
@@ -147,3 +152,75 @@ describe("Paseo tool-call detail presentation", () => {
|
||||
expect(buildPaseoToolDetailSections("mcp__github__create_issue", {}, {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("published Paseo tool-call detail entry", () => {
|
||||
it("preserves the 0.11.4 runtime export and types at the old subpath", () => {
|
||||
const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const compiler = fileURLToPath(
|
||||
new URL("../../../../node_modules/typescript/bin/tsc", import.meta.url),
|
||||
);
|
||||
const fixture = mkdtempSync(path.join(tmpdir(), "paseo-tool-call-detail-"));
|
||||
try {
|
||||
copyFileSync(path.join(packageRoot, "package.json"), path.join(fixture, "package.json"));
|
||||
symlinkSync(
|
||||
path.join(packageRoot, "../../node_modules"),
|
||||
path.join(fixture, "node_modules"),
|
||||
"junction",
|
||||
);
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
compiler,
|
||||
"-p",
|
||||
path.join(packageRoot, "tsconfig.json"),
|
||||
"--outDir",
|
||||
path.join(fixture, "dist"),
|
||||
"--incremental",
|
||||
"false",
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(fixture, "consumer.mts"),
|
||||
`
|
||||
import { strict as assert } from "node:assert";
|
||||
import {
|
||||
buildPaseoToolDetailSections,
|
||||
type PaseoToolDetailField,
|
||||
type PaseoToolDetailSection,
|
||||
} from "@getpaseo/protocol/paseo-tool-call-detail";
|
||||
import { buildPaseoToolDetailSections as current } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
const field: PaseoToolDetailField = { label: "Agent", value: "agt_123" };
|
||||
const sections: PaseoToolDetailSection[] = [
|
||||
{ kind: "prose", title: "Prompt", text: "Hello" },
|
||||
{ kind: "fields", title: "Details", fields: [field] },
|
||||
];
|
||||
const build: (toolName: string, input: unknown, output: unknown) => PaseoToolDetailSection[] | null = buildPaseoToolDetailSections;
|
||||
assert.equal(build, current);
|
||||
assert.deepEqual(build("mcp__paseo__send_agent_prompt", { prompt: "Hello", agentId: "agt_123" }, undefined), sections);
|
||||
`,
|
||||
);
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
compiler,
|
||||
path.join(fixture, "consumer.mts"),
|
||||
"--module",
|
||||
"NodeNext",
|
||||
"--moduleResolution",
|
||||
"NodeNext",
|
||||
"--target",
|
||||
"ES2020",
|
||||
"--strict",
|
||||
"--skipLibCheck",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(
|
||||
execFileSync(process.execPath, [path.join(fixture, "consumer.mjs")], { encoding: "utf8" }),
|
||||
).toBe("");
|
||||
} finally {
|
||||
rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const BUILTIN_PROVIDER_ICON_NAMES = [
|
||||
"antigravity",
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
@@ -22,6 +23,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
|
||||
"crow-cli",
|
||||
"cursor",
|
||||
"deepagents",
|
||||
"devin",
|
||||
"dimcode",
|
||||
"dirac",
|
||||
"factory-droid",
|
||||
@@ -31,6 +33,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
|
||||
"glm-acp-agent",
|
||||
"goose",
|
||||
"grok",
|
||||
"hermes",
|
||||
"hoplite",
|
||||
"junie",
|
||||
"kilo",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0-beta.2",
|
||||
"version": "0.12.0",
|
||||
"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",
|
||||
"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",
|
||||
"@getpaseo/highlight": "0.12.0",
|
||||
"@getpaseo/plugin": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Linux delivers the existing inventory as one batch. Other backends have
|
||||
// different startup semantics, so they do not exercise this regression.
|
||||
test.skipIf(process.platform !== "linux").each(["healthy", "directory-churn"])(
|
||||
"%s benchmark collects a startup batch larger than the argument limit",
|
||||
async (mode) => {
|
||||
const temporaryRoot = await mkdtemp(join(tmpdir(), "paseo-watch-benchmark-test-"));
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
// Lower the argument limit instead of creating hundreds of thousands
|
||||
// of files. This still runs the real observer and benchmark CLI.
|
||||
"--stack-size=256",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("./measure-file-observer.ts", import.meta.url)),
|
||||
"--child",
|
||||
],
|
||||
{
|
||||
timeout: 60_000,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: temporaryRoot,
|
||||
PASEO_WATCH_BENCH_MODE: mode,
|
||||
PASEO_WATCH_BENCH_ROOTS: "1",
|
||||
PASEO_WATCH_BENCH_DIRS: "10",
|
||||
PASEO_WATCH_BENCH_PREEXISTING_FILES: "40000",
|
||||
PASEO_WATCH_BENCH_IGNORED_FILES: "1",
|
||||
PASEO_WATCH_BENCH_EDITS: "1",
|
||||
PASEO_WATCH_BENCH_SUSTAINED_EDITS: "1",
|
||||
PASEO_WATCH_BENCH_SUSTAINED_INTERVAL_MS: "1",
|
||||
PASEO_WATCH_BENCH_CHURN_DIRECTORIES: "10",
|
||||
PASEO_WATCH_BENCH_CHURN_FILES_PER_DIRECTORY: "4000",
|
||||
PASEO_WATCH_BENCH_CHURN_ROUNDS: "1",
|
||||
PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND: "2",
|
||||
},
|
||||
},
|
||||
);
|
||||
const measurement = JSON.parse(stdout);
|
||||
expect(measurement.startupEvents).toBe(40_000);
|
||||
expect(measurement.deliveredEvents).toBeGreaterThan(measurement.startupEvents);
|
||||
expect(measurement.missedTrackedPaths).toBe(0);
|
||||
expect(measurement.ignoredEvents).toBe(0);
|
||||
} finally {
|
||||
await rm(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
90_000,
|
||||
);
|
||||
@@ -199,8 +199,8 @@ async function measure(run: number): Promise<Measurement> {
|
||||
root,
|
||||
(nextError, events) => {
|
||||
error ??= nextError;
|
||||
delivered.push(...events);
|
||||
for (const event of events) {
|
||||
delivered.push(event);
|
||||
if (!firstObservedAt.has(event.path))
|
||||
firstObservedAt.set(event.path, performance.now());
|
||||
}
|
||||
@@ -456,7 +456,7 @@ async function measureDirectoryChurn(run: number): Promise<Measurement> {
|
||||
const setupStarted = performance.now();
|
||||
subscription = await observer.subscribe(root, (nextError, events) => {
|
||||
error ??= nextError;
|
||||
delivered.push(...events);
|
||||
for (const event of events) delivered.push(event);
|
||||
});
|
||||
const setupMs = performance.now() - setupStarted;
|
||||
|
||||
|
||||
@@ -3654,6 +3654,8 @@ export class AgentManager {
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
this.emitState(managed, { persist: false });
|
||||
this.subscribeToSession(managed);
|
||||
await this.drainSessionEvents(managed.id);
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
return { ...managed };
|
||||
} catch (error) {
|
||||
if (!registered) {
|
||||
|
||||
@@ -64,7 +64,8 @@ import { createGitMutationService } from "../session/git-mutation/git-mutation-s
|
||||
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
|
||||
import type { ForgeService } from "../../services/forge-service.js";
|
||||
import { areEquivalentPaths } from "../../utils/path.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
|
||||
@@ -1251,6 +1252,168 @@ describe("browser MCP tools", () => {
|
||||
describe("terminal MCP tools", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it.each<[string, string, string, boolean]>([
|
||||
["Down", "\x1b[B", "", false],
|
||||
["Up", "\x1b[A", "", false],
|
||||
["Left", "\x1b[D", "", false],
|
||||
["Right", "\x1b[C", "", false],
|
||||
["PageUp", "\x1b[5~", "", false],
|
||||
["PageDown", "\x1b[6~", "", false],
|
||||
["Home", "\x1b[H", "", false],
|
||||
["End", "\x1b[F", "", false],
|
||||
["Insert", "\x1b[2~", "", false],
|
||||
["Delete", "\x1b[3~", "", false],
|
||||
["F1", "\x1bOP", "", false],
|
||||
["F5", "\x1b[15~", "", false],
|
||||
["F12", "\x1b[24~", "", false],
|
||||
["S-Tab", "\x1b[Z", "", false],
|
||||
["C-r", "\x12", "", false],
|
||||
["M-b", "\x1bb", "", false],
|
||||
["C-M-Left", "\x1b[1;7D", "", false],
|
||||
["C-S-Up", "\x1b[1;6A", "", false],
|
||||
["C-Space", "\x00", "", false],
|
||||
["Enter", "\r", "", false],
|
||||
["Tab", "\t", "", false],
|
||||
["Escape", "\x1b", "", false],
|
||||
["Space", " ", "", false],
|
||||
["BSpace", "\x7f", "", false],
|
||||
["C-c", "\x03", "", false],
|
||||
["C-d", "\x04", "", false],
|
||||
["C-z", "\x1a", "", false],
|
||||
["C-l", "\x0c", "", false],
|
||||
["C-a", "\x01", "", false],
|
||||
["C-e", "\x05", "", false],
|
||||
["Down", "\x1bOB", "\x1b[?1h", false],
|
||||
["S-Enter", "\x1b[13;2u", "\x1b[=1;1u", false],
|
||||
["C-Enter", "\x1b[13;28;13;1;8;1_", "\x1b[?9001h", false],
|
||||
["echo hello\r", "echo hello\r", "", false],
|
||||
["\x1b[B", "\x1b[B", "", false],
|
||||
["UnknownKey", "UnknownKey", "", false],
|
||||
["constructor", "constructor", "", false],
|
||||
["__proto__", "__proto__", "", false],
|
||||
["", "", "", false],
|
||||
["Down", "Down", "\x1b[?1h", true],
|
||||
["C-r", "C-r", "", true],
|
||||
["S-Tab", "S-Tab", "", true],
|
||||
])(
|
||||
"sends terminal token %j as %j (preamble=%j, literal=%s)",
|
||||
async (keys, data, preamble, literal) => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const send = vi.fn();
|
||||
const terminalManager = createTerminalManagerStub({
|
||||
getTerminal: vi.fn().mockReturnValue({
|
||||
send,
|
||||
getReplayPreamble: () => preamble,
|
||||
}),
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createClaudeOnlyManager(),
|
||||
terminalManager,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await registeredTool(server, "send_terminal_keys").handler({
|
||||
terminalId: "term-1",
|
||||
keys,
|
||||
literal,
|
||||
});
|
||||
expect(response.structuredContent).toEqual({ success: true });
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith({ type: "input", data });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["in-process", createTerminalManager],
|
||||
["worker", createWorkerTerminalManager],
|
||||
] as const)(
|
||||
"delivers MCP key tokens through %s after terminal input modes change",
|
||||
async (_name, createManager) => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const terminalManager = createManager();
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: process.cwd(),
|
||||
workspaceId: "ws-terminal-keys",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`process.stdin.setRawMode(true);
|
||||
process.stdout.write("READY\\r\\n");
|
||||
process.stdin.on("data", data => {
|
||||
if (data.toString() === "application") {
|
||||
process.stdout.write("\\x1b[?1hAPPLICATION_READY\\r\\n");
|
||||
return;
|
||||
}
|
||||
if (data.toString() === "normal") {
|
||||
process.stdout.write("\\x1b[?1lNORMAL_READY\\r\\n");
|
||||
return;
|
||||
}
|
||||
process.stdout.write("HEX:" + data.toString("hex") + "\\r\\n");
|
||||
});`,
|
||||
],
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createClaudeOnlyManager(),
|
||||
terminalManager,
|
||||
logger,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
const unsubscribe = terminal.subscribe((message) => {
|
||||
if (message.type === "output") {
|
||||
output += message.data;
|
||||
}
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const capture = await terminalManager.captureTerminal(terminal.id);
|
||||
expect(capture.lines).toContain("READY");
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
const tool = registeredTool(server, "send_terminal_keys");
|
||||
await tool.handler({ terminalId: terminal.id, keys: "application", literal: true });
|
||||
await vi.waitFor(() => expect(output).toContain("APPLICATION_READY"));
|
||||
for (const [keys, hex] of [
|
||||
["Down", "1b4f42"],
|
||||
["Up", "1b4f41"],
|
||||
["Left", "1b4f44"],
|
||||
["Right", "1b4f43"],
|
||||
["PageDown", "1b5b367e"],
|
||||
["S-Tab", "1b5b5a"],
|
||||
["C-r", "12"],
|
||||
["M-b", "1b62"],
|
||||
]) {
|
||||
await tool.handler({ terminalId: terminal.id, keys });
|
||||
await vi.waitFor(async () => {
|
||||
const capture = await terminalManager.captureTerminal(terminal.id);
|
||||
expect(capture.lines.join("\n")).toContain(`HEX:${hex}`);
|
||||
});
|
||||
}
|
||||
await tool.handler({ terminalId: terminal.id, keys: "normal", literal: true });
|
||||
await vi.waitFor(() => expect(output).toContain("NORMAL_READY"));
|
||||
await tool.handler({ terminalId: terminal.id, keys: "Down" });
|
||||
await vi.waitFor(async () => {
|
||||
const capture = await terminalManager.captureTerminal(terminal.id);
|
||||
expect(capture.lines).toContain("HEX:1b5b42");
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await server.close();
|
||||
await terminalManager.killTerminalAndWait(terminal.id);
|
||||
terminalManager.killAll();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("captures terminal output through the terminal manager authority", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const captureTerminal = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PassThrough } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentPermissionRequest,
|
||||
AgentSession,
|
||||
@@ -94,6 +95,7 @@ import {
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { buildProviderRegistry } from "../provider-registry.js";
|
||||
|
||||
interface CollaborationModeRecord {
|
||||
@@ -7073,3 +7075,95 @@ describe("Codex denied plan approvals", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex interactive resume startup", () => {
|
||||
test("delivers an autonomous turn that starts during resume to the first subscriber", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/interrupt": () => ({}),
|
||||
"thread/resume": () => {
|
||||
appServer.startsTurn({ threadId: "saved-thread", turnId: "autonomous-turn" });
|
||||
appServer.says({ threadId: "saved-thread", text: "Working on the goal" });
|
||||
return { thread: { id: "saved-thread" } };
|
||||
},
|
||||
"thread/read": () => ({ thread: { turns: [] } }),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ modeId: "full-access" }),
|
||||
{ sessionId: "saved-thread" },
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
try {
|
||||
await session.connect();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
expect(events.some((event) => event.type === "turn_started")).toBe(true);
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("Working on the goal"),
|
||||
),
|
||||
).toBe(true);
|
||||
await session.interrupt();
|
||||
expect(
|
||||
appServer.requests().find((request) => request.method === "turn/interrupt"),
|
||||
).toMatchObject({ params: { threadId: "saved-thread", turnId: "autonomous-turn" } });
|
||||
unsubscribe();
|
||||
const laterEvents: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => laterEvents.push(event));
|
||||
expect(laterEvents).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
}
|
||||
});
|
||||
test.each([false, true])(
|
||||
"applies resumed goal events before returning (completed during resume: %s)",
|
||||
async (completedDuringResume) => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/resume": () => {
|
||||
appServer.startsTurn({ threadId: "saved-thread", turnId: "autonomous-turn" });
|
||||
if (completedDuringResume) appServer.completeTurn({ threadId: "saved-thread" });
|
||||
return { thread: { id: "saved-thread" } };
|
||||
},
|
||||
"thread/read": () => ({ thread: { turns: [] } }),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ title: "Resumed goal", cwd: process.cwd() }),
|
||||
{ sessionId: "saved-thread" },
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const client: AgentClient = {
|
||||
provider: "codex",
|
||||
capabilities: session.capabilities,
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog: async () => ({ models: [], modes: [] }),
|
||||
createSession: async () => {
|
||||
throw new Error("Unexpected create");
|
||||
},
|
||||
resumeSession: async () => {
|
||||
await session.connect();
|
||||
return session;
|
||||
},
|
||||
};
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
|
||||
let id: string | undefined;
|
||||
try {
|
||||
const resumed = await manager.resumeAgentFromPersistence(
|
||||
{ provider: "codex", sessionId: "saved-thread" },
|
||||
createConfig({ title: "Resumed goal", cwd: process.cwd() }),
|
||||
);
|
||||
id = resumed.id;
|
||||
expect(resumed.lifecycle).toBe(completedDuringResume ? "idle" : "running");
|
||||
expect(manager.getAgent(id)?.lifecycle).toBe(completedDuringResume ? "idle" : "running");
|
||||
} finally {
|
||||
if (id) await manager.closeAgent(id);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3453,6 +3453,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private client: CodexAppServerClient | null = null;
|
||||
private threadRollbackAvailable = true;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private startupEvents: AgentStreamEvent[] | null = null;
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
@@ -3571,6 +3572,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (this.resumeHandle?.sessionId) {
|
||||
this.currentThreadId = this.resumeHandle.sessionId;
|
||||
this.historyPending = true;
|
||||
// Resuming can start a goal before the caller receives the session.
|
||||
if (this.initialResumePurpose === "interactive") this.startupEvents = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4543,6 +4546,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
const startupEvents = this.startupEvents;
|
||||
this.startupEvents = null;
|
||||
for (const event of startupEvents ?? []) this.notifySubscribers(event);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
@@ -5028,6 +5034,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.clearPendingPermissions();
|
||||
this.pendingSubAgentNotificationsByThreadId.clear();
|
||||
this.subscribers.clear();
|
||||
this.startupEvents = null;
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.pendingForegroundTurnIdentification?.resolve(null);
|
||||
@@ -5375,6 +5382,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId;
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
if (this.startupEvents) {
|
||||
this.startupEvents.push(tagged);
|
||||
return;
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ import type { FirstAgentContext } from "../../messages.js";
|
||||
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
||||
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
|
||||
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
|
||||
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
|
||||
import { computeNextRunAt, validateScheduleCadence } from "../../schedule/cron.js";
|
||||
import {
|
||||
@@ -594,37 +596,44 @@ const TerminalSummarySchema = z.object({
|
||||
cwd: z.string(),
|
||||
});
|
||||
|
||||
function resolveTerminalKeyToken(key: string, literal: boolean): string {
|
||||
interface TerminalKeyTokenInput {
|
||||
key: string;
|
||||
literal: boolean;
|
||||
preamble: string;
|
||||
}
|
||||
|
||||
function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInput): string {
|
||||
if (literal) {
|
||||
return key;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "Enter":
|
||||
return "\r";
|
||||
case "Tab":
|
||||
return "\t";
|
||||
case "Escape":
|
||||
return "\u001b";
|
||||
case "Space":
|
||||
return " ";
|
||||
case "BSpace":
|
||||
return "\u007f";
|
||||
case "C-c":
|
||||
return "\u0003";
|
||||
case "C-d":
|
||||
return "\u0004";
|
||||
case "C-z":
|
||||
return "\u001a";
|
||||
case "C-l":
|
||||
return "\u000c";
|
||||
case "C-a":
|
||||
return "\u0001";
|
||||
case "C-e":
|
||||
return "\u0005";
|
||||
default:
|
||||
return key;
|
||||
const match = /^((?:[CMS]-)*)(.+)$/.exec(key);
|
||||
if (!match) {
|
||||
return key;
|
||||
}
|
||||
const [, modifiers, token] = match;
|
||||
const aliases: Record<string, string> = {
|
||||
Up: "ArrowUp",
|
||||
Down: "ArrowDown",
|
||||
Left: "ArrowLeft",
|
||||
Right: "ArrowRight",
|
||||
BSpace: "Backspace",
|
||||
Space: " ",
|
||||
};
|
||||
const inputMode = new TerminalInputModeTracker();
|
||||
inputMode.feed(preamble);
|
||||
const encodedKey = Object.hasOwn(aliases, token) ? aliases[token] : token;
|
||||
return (
|
||||
encodeTerminalKeyInput(
|
||||
{
|
||||
key: encodedKey,
|
||||
ctrl: modifiers.includes("C-"),
|
||||
alt: modifiers.includes("M-"),
|
||||
shift: modifiers.includes("S-"),
|
||||
},
|
||||
{ inputMode: inputMode.getState() },
|
||||
) || key
|
||||
);
|
||||
}
|
||||
|
||||
export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog {
|
||||
@@ -2591,7 +2600,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
"send_terminal_keys",
|
||||
{
|
||||
title: "Send terminal keys",
|
||||
description: "Send literal text or special key tokens to a terminal session.",
|
||||
description:
|
||||
"Send text or a key token: Enter, Tab, Escape, Space, BSpace, Up, Down, Left, Right, Home, End, Insert, Delete, PageUp, PageDown, or F1-F12. Combine C- (Ctrl), M- (Alt), and S- (Shift) prefixes, e.g. C-r, S-Tab, or C-M-Left. Set literal=true to send the text unchanged.",
|
||||
inputSchema: {
|
||||
terminalId: z.string(),
|
||||
keys: z.string(),
|
||||
@@ -2613,7 +2623,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
|
||||
terminal.send({
|
||||
type: "input",
|
||||
data: resolveTerminalKeyToken(keys, literal),
|
||||
data: resolveTerminalKeyToken({
|
||||
key: keys,
|
||||
literal,
|
||||
preamble: terminal.getReplayPreamble(),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -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,5 +1,72 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { UsageSourceRegistry } from "./index.js";
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
discoverSession as discoverCursorSession,
|
||||
fetchUsage as fetchCursorUsage,
|
||||
} from "../../../../../../plugins/cursor-usage-source/server/usage.js";
|
||||
import { inputSchema as cursorInputSchema } from "../../../../../../plugins/cursor-usage-source/shared/input.js";
|
||||
|
||||
test("#7142: host usage retains distinct Cursor accounts after their individual details are read", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "cursor-accounts-"));
|
||||
try {
|
||||
const sessions = new Map<
|
||||
string,
|
||||
{ provider: string; sessionKey: string; env: Record<string, string> }
|
||||
>();
|
||||
const spends = new Map<string, number>();
|
||||
for (const [agentId, account, spend] of [
|
||||
["a", "user-a", 100],
|
||||
["b", "user-b", 200],
|
||||
["a-copy", "user-a", 100],
|
||||
] as const) {
|
||||
const configHome = join(root, agentId);
|
||||
const token = `e30.${Buffer.from(JSON.stringify({ sub: account, nonce: agentId })).toString("base64url")}.signature`;
|
||||
await mkdir(join(configHome, "cursor"), { recursive: true });
|
||||
await writeFile(
|
||||
join(configHome, "cursor", "auth.json"),
|
||||
JSON.stringify({ accessToken: token }),
|
||||
);
|
||||
spends.set(`Bearer ${token}`, spend);
|
||||
sessions.set(agentId, {
|
||||
provider: "cursor",
|
||||
sessionKey: agentId,
|
||||
env: { XDG_CONFIG_HOME: configHome },
|
||||
});
|
||||
}
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
|
||||
hasAgent: (id) => sessions.has(id),
|
||||
usageSession: (id) => sessions.get(id) ?? null,
|
||||
});
|
||||
const fetchApi: typeof fetch = async (_url, init) => {
|
||||
const authorization = new Headers(init?.headers).get("Authorization") ?? "";
|
||||
const spend = spends.get(authorization);
|
||||
if (spend === undefined) throw new Error("Unexpected fixture login");
|
||||
return new Response(JSON.stringify({ planUsage: { totalSpend: spend, limit: 1000 } }));
|
||||
};
|
||||
registry.register({
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
discover: (scope) =>
|
||||
scope.kind === "session" ? discoverCursorSession(scope, "linux") : Promise.resolve([]),
|
||||
fetch: (input) => fetchCursorUsage(cursorInputSchema.parse(input), fetchApi),
|
||||
});
|
||||
const spent = (reports: Awaited<ReturnType<typeof registry.listReports>>) =>
|
||||
reports.map((entry) => {
|
||||
if (entry.report.status !== "available")
|
||||
throw new Error("Expected available fixture usage");
|
||||
return entry.report.balances?.[0]?.used;
|
||||
});
|
||||
expect(spent(await registry.listReports({ agentId: "a" }))).toEqual([1]);
|
||||
expect(spent(await registry.listReports({ agentId: "b" }))).toEqual([2]);
|
||||
expect(spent(await registry.listReports({ agentId: "a-copy" }))).toEqual([1]);
|
||||
expect(spent(await registry.listReports())).toEqual([1, 2]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function source(input: {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
// Opt in on Linux with passwordless sudo, unshare, ip, mount, and tc available:
|
||||
// RUN_RELAY_LATENCY_E2E=1 npx vitest run src/server/relay-transport.latency.e2e.test.ts
|
||||
// All networking and the hosts-file mount are private to the child namespaces.
|
||||
test.runIf(process.platform === "linux" && process.env.RUN_RELAY_LATENCY_E2E === "1")(
|
||||
"connects relay control and data over a slow dual-stack route with an unreachable address",
|
||||
async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-latency-"));
|
||||
try {
|
||||
const hosts = path.join(directory, "hosts");
|
||||
await writeFile(
|
||||
hosts,
|
||||
`${await readFile("/etc/hosts", "utf8")}\n127.0.0.1 relay-latency.test\n2001:db8:1::1 relay-latency.test\n`,
|
||||
);
|
||||
const fixture = fileURLToPath(
|
||||
new URL("./test-utils/relay-latency-fixture.ts", import.meta.url),
|
||||
);
|
||||
for (const order of ["ipv4first", "ipv6first"]) {
|
||||
const { stdout } = await run(
|
||||
"sudo",
|
||||
[
|
||||
"-n",
|
||||
"unshare",
|
||||
"--mount",
|
||||
"--net",
|
||||
"sh",
|
||||
"-ec",
|
||||
`mount --make-rprivate /
|
||||
mount --bind "$1" /etc/hosts
|
||||
ip link set lo up
|
||||
ip link add relay-test type dummy
|
||||
ip addr add 192.0.2.2/32 dev relay-test
|
||||
ip -6 addr add 2001:db8::2/128 dev relay-test
|
||||
ip link set relay-test up
|
||||
tc qdisc add dev lo root netem delay 350ms
|
||||
exec "$2" --import tsx "$3" "$4"`,
|
||||
"sh",
|
||||
hosts,
|
||||
process.execPath,
|
||||
fixture,
|
||||
order,
|
||||
],
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
expect(stdout).toContain("relay data round trip passed");
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
@@ -56,7 +56,13 @@ type ControlMessage =
|
||||
const CONTROL_PING_INTERVAL_MS = 10_000;
|
||||
const CONTROL_STALE_TIMEOUT_MS = 30_000;
|
||||
const CONTROL_READY_TIMEOUT_MS = 8_000;
|
||||
const RELAY_WEBSOCKET_OPTIONS = { handshakeTimeout: 10_000, perMessageDeflate: false } as const;
|
||||
const RELAY_WEBSOCKET_OPTIONS = {
|
||||
handshakeTimeout: 10_000,
|
||||
perMessageDeflate: false,
|
||||
// Node cancels each non-final address when this expires, even if the next
|
||||
// address is unreachable. Allow high-latency routes time to connect.
|
||||
autoSelectFamilyAttemptTimeout: 5_000,
|
||||
} as const;
|
||||
|
||||
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
|
||||
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import dns from "node:dns";
|
||||
import { once } from "node:events";
|
||||
import net from "node:net";
|
||||
import { Writable } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { startRelayTransport } from "../relay-transport.js";
|
||||
|
||||
// Match the reported runtime even on Node versions with a newer default.
|
||||
net.setDefaultAutoSelectFamilyAttemptTimeout(250);
|
||||
const order = process.argv[2];
|
||||
assert.ok(order === "ipv4first" || order === "ipv6first");
|
||||
dns.setDefaultResultOrder(order);
|
||||
|
||||
const logs: string[] = [];
|
||||
const logger = pino(
|
||||
{ level: "debug" },
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
logs.push(chunk.toString());
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const relay = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
await once(relay, "listening");
|
||||
const address = relay.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
relay.on("connection", (socket, request) => {
|
||||
const url = new URL(request.url!, "http://relay-latency.test");
|
||||
if (url.searchParams.has("connectionId")) {
|
||||
socket.on("message", (data) => socket.send(data));
|
||||
} else {
|
||||
socket.send(JSON.stringify({ type: "sync", connectionIds: ["latency-client"] }));
|
||||
}
|
||||
});
|
||||
|
||||
let complete!: () => void;
|
||||
let fail!: (error: Error) => void;
|
||||
const delivered = new Promise<void>((resolve, reject) => {
|
||||
complete = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
const deadline = setTimeout(() => fail(new Error(logs.join(""))), 8_000);
|
||||
const transport = startRelayTransport({
|
||||
logger,
|
||||
relayEndpoint: `relay-latency.test:${address.port}`,
|
||||
relayUseTls: false,
|
||||
serverId: "latency-server",
|
||||
attachSocket: async (socket) => {
|
||||
socket.on("message", (data) => {
|
||||
try {
|
||||
assert.equal(String(data), "latency round trip");
|
||||
complete();
|
||||
} catch (error) {
|
||||
fail(error as Error);
|
||||
}
|
||||
});
|
||||
socket.send("latency round trip");
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await delivered;
|
||||
assert.ok(logs.some((line) => JSON.parse(line).msg === "relay_control_connected"));
|
||||
assert.ok(logs.some((line) => JSON.parse(line).msg === "relay_data_connected"));
|
||||
assert.ok(!logs.some((line) => JSON.parse(line).msg === "relay_error"));
|
||||
console.log("relay data round trip passed");
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
await transport.stop();
|
||||
for (const socket of relay.clients) socket.terminate();
|
||||
await new Promise<void>((resolve) => relay.close(() => resolve()));
|
||||
}
|
||||
@@ -125,6 +125,7 @@ function watchTerminal(session: TerminalSession): void {
|
||||
type: "terminalMessage",
|
||||
terminalId: session.id,
|
||||
message: { type: "output", data: payload.toString("utf8"), revision },
|
||||
replayPreamble: session.getReplayPreamble(),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -144,6 +145,7 @@ function watchTerminal(session: TerminalSession): void {
|
||||
type: "terminalMessage",
|
||||
terminalId: session.id,
|
||||
message,
|
||||
replayPreamble: session.getReplayPreamble(),
|
||||
});
|
||||
},
|
||||
// Creation already sends an authoritative state in terminalCreated. A second
|
||||
|
||||
@@ -121,6 +121,7 @@ export type TerminalWorkerEvent =
|
||||
type: "terminalMessage";
|
||||
terminalId: string;
|
||||
message: ServerMessage;
|
||||
replayPreamble: string;
|
||||
}
|
||||
| {
|
||||
type: "terminalExit";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user