Compare commits

...

375 Commits

Author SHA1 Message Date
Mohamed Boudra
7048cd5c86 chore(release): cut 0.1.90 2026-06-04 20:37:23 +07:00
Mohamed Boudra
9dbc75e78e Prepare 0.1.90 stable release 2026-06-04 20:36:11 +07:00
Mohamed Boudra
81d973ffc4 Fix diff gutter row alignment 2026-06-04 19:44:45 +07:00
Mohamed Boudra
3d8ef237e3 Fix workspace tab timeline flicker 2026-06-04 19:10:33 +07:00
Mohamed Boudra
74d42cb3d0 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 17:51:24 +07:00
Mohamed Boudra
1bca88bd30 Show initial-agent workspaces as Working 2026-06-04 17:49:13 +07:00
Mohamed Boudra
1daa4587e5 Add Dart syntax highlighting (#1326) 2026-06-04 18:26:23 +08:00
Mohamed Boudra
88d44ab2c5 Show free-write answers for OpenCode questions 2026-06-04 15:13:47 +07:00
Mohamed Boudra
3ecf86933f Fix OpenCode child auto accept inheritance 2026-06-04 15:12:12 +07:00
Mohamed Boudra
1b350ac09e Lock skills update status invariant 2026-06-04 15:08:28 +07:00
Mohamed Boudra
de9d796567 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 14:23:00 +07:00
Mohamed Boudra
cd8e0a5e08 Fix sidebar selection and Unistyles warnings
Forward selection state into native draggable lists so virtualized workspace rows update when the active route changes.

Keep floating caller styles array-shaped and strip Unistyles metadata from owned inline geometry to avoid runtime style warnings.
2026-06-04 14:17:08 +07:00
Mohamed Boudra
38f8179bc1 Add a global New workspace picker (#1324)
* Add global New workspace project picker

* Clean up New workspace picker state
2026-06-04 15:16:41 +08:00
Mohamed Boudra
0b62e3bd7f Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 13:38:54 +07:00
Mohamed Boudra
f0370a3c20 Fix long dictation retry backpressure
Treat Node IPC backpressure as a queue signal instead of a failed send, and pace buffered dictation replay so finish waits for the backlog to flush.
2026-06-04 13:35:10 +07:00
Mohamed Boudra
e381117b1c Limit OpenCode metadata refresh concurrency 2026-06-04 13:32:51 +07:00
Mohamed Boudra
f9791d7ab6 Fix mobile sidebar swipes under load
Move mobile sidebar gesture gating to shared visual panel state so swipes can open and close sidebars before React state catches up. Keep the sidebar list scroll simultaneous with the close pan so vertical scrolling stays immediate.
2026-06-04 13:32:15 +07:00
Mohamed Boudra
66a5bd8a88 Fix sidebar workspace row alignment 2026-06-04 13:26:45 +07:00
Mohamed Boudra
de5fa4dc36 Publish beta npm packages under beta tag 2026-06-04 11:53:58 +07:00
Mohamed Boudra
6dcf3ffd6a Keep archived workspaces recent in Done 2026-06-04 11:46:04 +07:00
Mohamed Boudra
f139966554 Fix focus mode tab padding 2026-06-04 11:08:37 +07:00
Mohamed Boudra
b0c4cc99ed Fix settings host picker navigation 2026-06-04 09:37:14 +07:00
Mohamed Boudra
3e3e944cf4 Fix compact sheet bottom spacing 2026-06-04 09:28:05 +07:00
Mohamed Boudra
f3b7dac768 Fix compact provider sheet footer 2026-06-04 08:46:47 +07:00
paseo-ai[bot]
b9f3e59d48 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-03 18:06:46 +00:00
Mohamed Boudra
801140abb2 chore(release): cut 0.1.90-beta.1 2026-06-04 01:02:54 +07:00
Mohamed Boudra
b345ef1242 Stop counting initializing agents as running 2026-06-04 00:53:38 +07:00
Mohamed Boudra
f6c0b60b7c Fix sidebar shortcut hints shifting rows 2026-06-04 00:48:53 +07:00
Mohamed Boudra
ffe4d4046c Update ACP provider catalog pins 2026-06-04 00:27:46 +07:00
Mohamed Boudra
7f9582e197 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 00:22:40 +07:00
Mohamed Boudra
6e8b7aaa07 Show PR action when auto-merge is enabled 2026-06-04 00:21:33 +07:00
Mohamed Boudra
31ae545289 Group and clear workspace status in the sidebar (#1317)
* Add status grouping to the workspace sidebar

* Mark workspaces as read from the sidebar

* Clean package builds before release checks

* Fix sidebar status follow-up checks
2026-06-04 01:20:20 +08:00
Mohamed Boudra
71ce96b913 Prioritize ready actions and scheduled agent prompts (#1316)
* Sort sessions by last activity

* Prioritize shipping actions in git controls

* Fix scheduled new agent titles
2026-06-03 16:16:05 +00:00
Mohamed Boudra
eba8de6ab5 Speed up session import 2026-06-03 23:09:45 +07:00
Mohamed Boudra
4c730611e6 Fix markdown code rendering in file previews 2026-06-03 23:08:47 +07:00
Mohamed Boudra
bf81496a4f Merge branch 'main' of github.com:getpaseo/paseo 2026-06-03 23:00:52 +07:00
Mohamed Boudra
6d29092fe6 Fix older timeline loading after catch-up 2026-06-03 22:32:12 +07:00
Mohamed Boudra
b3ba1572b3 Keep child agents unattended across providers (#1315)
* Keep child agents unattended across providers

* Clean up unattended create config resolution

* Make provider catalog tests tolerate narrow CI fixtures
2026-06-03 23:17:14 +08:00
Mohamed Boudra
369adced52 Archive merged PR workspaces from settings (#1313)
* Add workspace settings for merged PR cleanup

* Fix worktree branch ahead status

* Handle PR worktree upstream status

* Remove stale checkout tracking field

* Update settings E2E host section slugs

* Treat unknown upstream as unsafe for auto-archive
2026-06-03 23:17:08 +08:00
Mohamed Boudra
b510244518 Prioritize shipping actions in git controls 2026-06-03 22:12:27 +07:00
Mohamed Boudra
c44ad7ecd1 Sort sessions by last activity 2026-06-03 22:00:52 +07:00
Mohamed Boudra
bd4889e243 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-03 19:34:57 +07:00
Mohamed Boudra
3634f5e2d8 Fix compact composer flash on tab switch 2026-06-03 19:32:49 +07:00
Mohamed Boudra
76ae8c19b0 Show delegated agent activity on parent workspaces 2026-06-03 19:10:05 +07:00
Mohamed Boudra
91ef9301c2 Show question prompts one at a time
* Show question prompts one at a time

* Make question prompt submit action literal
2026-06-03 19:49:36 +08:00
Mohamed Boudra
510a299420 Update epic PR babysitting guidance 2026-06-03 18:33:13 +07:00
Mohamed Boudra
38d690e425 Show attachment actions in a mobile sheet 2026-06-03 18:30:43 +07:00
Aaron Zhong
e9a746d4ea feat: open active files in editors and file managers (#1285)
* feat: open active files in editors and file managers

* fix: open the workspace folder alongside the active file in editors

Editor targets received only the file path, so opening from the workspace
file explorer launched the bare file without the surrounding project. Thread
an optional workspace `cwd` through the open_in_editor request so editors
launch as `<workspace> <file>`, loading the project explorer plus the file.
File-manager targets keep their single-path behavior.

* fix: address PR review feedback on file-open path resolution

- Narrow the tilde guard so only genuine home-relative paths (~ or ~/…)
  are rejected; filenames like ~env.ts are now treated as workspace-relative.
- Make the workspace-containment check case-insensitive for Windows drive
  paths so case-only differences still resolve a relative path.
- Centralize the file-manager target id set in the protocol
  (isFileManagerTargetId) so the server and app no longer duplicate it.

* feat(desktop): open workspace files from Electron

* test(desktop): avoid mocked Electron editor IPC

* fix(desktop): avoid Windows shell for editor executables

* test(app): mock desktop editor bridge in e2e

* fix(app): reuse resolved workspace open target path

* fix(app): stabilize active file memo deps

* fix(protocol): tag legacy editor RPC shims

* fix(desktop): allow custom editor target ids

* fix(desktop): open Windows editor paths with metacharacters

Quote metacharacter values for .cmd editor wrappers so cmd.exe preserves them across wrapper execution instead of passing literal carets through.

* fix(app): preserve reviewed file path handling

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-03 19:20:30 +08:00
Mohamed Boudra
c0a0e3a1c6 Keep desktop skills up to date automatically (#1309)
* Keep installed desktop skills up to date

* Preserve user files during skill sync
2026-06-03 11:09:02 +00:00
Mohamed Boudra
3681fd132b Open workspace files in desktop targets 2026-06-03 18:34:24 +08:00
Mohamed Boudra
bb18cf618a Write persisted files atomically 2026-06-03 17:25:07 +07:00
Mohamed Boudra
fbaa0caedb Persist submitted ACP user messages 2026-06-03 17:22:53 +07:00
Mohamed Boudra
45f412456c Improve switch contrast in zinc theme 2026-06-03 16:02:55 +07:00
Mohamed Boudra
9f67542bd5 Keep keyboard dismissed for selector sheets 2026-06-03 15:53:41 +07:00
Mohamed Boudra
b4fd965c43 Dismiss keyboard when opening selectors 2026-06-03 15:27:51 +07:00
Mohamed Boudra
b365d818f3 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-03 15:17:03 +07:00
Mohamed Boudra
6f6c939624 Pin ACP catalog entries to current versions 2026-06-03 14:08:56 +07:00
paseo-ai[bot]
0d98df4a00 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-02 13:55:29 +00:00
Mohamed Boudra
5aecd36194 chore(release): cut 0.1.89 2026-06-02 20:51:07 +07:00
Mohamed Boudra
539441b3d5 Add 0.1.89 changelog 2026-06-02 20:49:48 +07:00
Mohamed Boudra
4b9eff1759 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-02 20:31:09 +07:00
Mohamed Boudra
48bc2f3166 Bump Cline ACP catalog entry to v3 2026-06-02 20:30:51 +07:00
Mohamed Boudra
14af053c66 Keep delegated agents out of workspace alerts (#1293)
* Keep delegated agents out of workspace alerts

* Share delegated agent label parsing
2026-06-02 20:55:19 +08:00
Mohamed Boudra
4f3116d28a Unify workspace service URLs (#1288)
* Unify workspace service proxying

* Clean up service proxy review issues
2026-06-02 19:49:58 +08:00
Mohamed Boudra
5666b3014a Fix Pi extension command turns (#1290) 2026-06-02 10:09:50 +00:00
Mohamed Boudra
07ace19b69 Keep fork PR badges visible in worktrees 2026-06-02 15:38:27 +07:00
Matt Cowger
f1a20a9fe9 Add configurable service proxy (#1280)
* Add configurable service proxy

* Fix service proxy URL used when opening scripts with a public base URL

When a public proxy URL was configured, the View link was falling through
to direct connection logic and opening the wrong URL. Move the public URL
early-return before connection-type checks, and remove the now-dead
duplicate check further down.

* Add service proxy documentation

Covers config options, URL generation format, wildcard DNS setup,
and reverse proxy Host header requirements.

* Cap combined public service label and document hostname format

* Address service proxy review feedback
2026-06-02 14:08:41 +08:00
Mohamed Boudra
eac5d93f78 Tune hero mockup sidebar, composer, and tabs 2026-06-01 23:39:02 +07:00
Mohamed Boudra
541e4c04cf Lower compact composer breakpoint to 500, add footer bottom padding 2026-06-01 22:29:58 +07:00
Mohamed Boudra
ce8ad8c68f Let composer controls adapt to narrow panes (#1270) 2026-06-01 22:58:03 +08:00
Éverton Toffanetto
1ede1bccf7 feat(desktop): persist and restore window state
Fixes #1223
2026-06-01 12:04:58 +00:00
Mohamed Boudra
a5c2f39f65 Tighten social proof marquee spacing 2026-06-01 18:04:20 +07:00
Mohamed Boudra
a807eb0eb2 Update homepage messaging and carousel performance 2026-06-01 16:30:17 +07:00
Matt Cowger
d406c29e16 Add configurable worktree root
* Add configurable worktree root

Allow new Paseo worktrees to be created under a custom worktrees.root from config.json while preserving the existing PASEO_HOME/worktrees default. Thread the resolved root through create/list/archive/ownership flows, MCP/session paths, checkout metadata, schema, docs, and targeted tests.

* Address worktree root review feedback

Use the resolved Paseo worktree base when a custom PASEO_HOME is provided, and remove an unused worktreesRoot dependency from worktree list handling.

* Fix checkout worktree path test on Windows

Use platform-native paths in the custom PASEO_HOME regression test so Windows CI compares against the same resolved root shape as production code.

* Clarify optional worktrees config docs

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-01 16:19:29 +08:00
Geoffrey Marc
8c6abcb41f fix(app): render bold/italic/strikethrough and line breaks via UITextView span on iOS (#1254)
* fix(app): render bold/italic/strikethrough via UITextView span on iOS

strong/em/s had no custom render rule and fell back to
react-native-markdown-display's defaults, which wrap children in a plain
RN <Text>. On iOS the paragraph/textgroup are native UITextViews
(markdown-text.ios.tsx); a plain <Text> nested inside is not hoisted into
a UITextViewChild, so its content rendered invisibly — bold/italic/
strikethrough silently disappeared (regression from #1153, v0.1.84).

Route strong/em/s through MarkdownInheritedText -> MarkdownTextSpan, the
same path text/textgroup already use, so the styled run composes as a
UITextViewChild on iOS and stays visible + selectable. Web/Android use a
plain <Text> for MarkdownTextSpan, so behavior there is unchanged.

Fixes #1253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): render hard/soft line breaks via UITextView span on iOS

hardbreak/softbreak had no custom rule, so react-native-markdown-display
emitted a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
plain <Text> is dropped (same root cause as strong/em/s/link): on iOS
hard line breaks vanished, and a softbreak between words joined them
("one\ntwo" -> "onetwo") — the "adjacent words dropped" symptom from the
original report.

Route both through MarkdownTextSpan so the newline composes as a
UITextViewChild on iOS; web/Android keep the same "\n".

Refs #1253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:57:06 +08:00
Matt Cowger
b8ccd543c4 fix: archive worktrees even when teardown fails
* Archive workspace record even if teardown fails

* Fix teardown test for cross-platform commands
2026-06-01 15:34:51 +08:00
Éverton Toffanetto
ae5dfc0b3c Fix split-pane right-edge resize clipping (#1261)
* fix(app): clamp split pane resize

Prevent right-edge drags from overflowing the Electron viewport.

* fix(app): isolate resize pointer state

Keep concurrent resize drags from clobbering each other.
2026-06-01 15:32:26 +08:00
Mohamed Boudra
539d2969f1 Add homepage social proof 2026-06-01 14:06:12 +07:00
Mohamed Boudra
7fb3dda20c Add detached agents and heartbeat scheduling (#1266) 2026-06-01 14:07:31 +08:00
paseo-ai[bot]
89ec358d00 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-01 04:31:01 +00:00
Mohamed Boudra
0efa0f3c42 chore(release): cut 0.1.88 2026-06-01 11:26:48 +07:00
Mohamed Boudra
13a1d5ffba Update changelog date for 0.1.88 2026-06-01 11:23:45 +07:00
Mohamed Boudra
f9f6ff2dbc Restore canonical timeline fetches
Older clients still request canonical rows for catch-up. Honor that explicit request while leaving projected as the default and projected app sync path.

Update the pending 0.1.88 changelog with the fixes already on main.
2026-06-01 11:17:19 +07:00
fireblue
3095bcb760 fix(desktop): stop pinning macOS displays at max refresh rate (#1242)
The macOS compositor watchdog called webContents.setBackgroundThrottling(false)
unconditionally for the window's whole lifetime. That disables Chromium's idle
frame-rate throttling, so the GPU process's CADisplayLink never idles — pinning
ProMotion panels at their maximum refresh rate (120Hz) continuously, even while
the app is idle or occluded, which drains the battery.

The call was never needed: the probe already skips non-producing windows via its
screen-lock / isVisible / isMinimized / document.visibilityState guards, and the
freeze it targets happens while the window is visible and focused, where
background throttling never applies. Removing it lets the compositor idle and the
display drop to a low adaptive rate when nothing animates; freeze detection is
unaffected. A comment and a docs note keep it from being re-added.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:42:22 +08:00
Mohamed Boudra
eb7495d231 Use Fastlane deliver for review resubmission 2026-06-01 10:38:20 +07:00
Mohamed Boudra
357c31d16e Clarify mobile release completion checks 2026-06-01 10:30:39 +07:00
Geoffrey Marc
a3b0af9ac0 fix(app): render inline links via UITextView span on iOS (#1257)
AssistantMarkdownLink's native branch wrapped its children in a plain RN
<Text>. On iOS that <Text> sits inside the paragraph's native UITextView
and is not hoisted into a UITextViewChild, so link text (markdown links,
bare URLs, and inline-code path links) was silently dropped — the same
root cause as the bold/italic/strikethrough fix in this PR.

Route the native link through MarkdownTextSpan so it composes as a
UITextViewChild and stays visible + selectable. MarkdownTextSpan now
forwards onPress/accessibilityRole across platforms: tappable on
Android/web; on iOS react-native-uitextview nulls onPress on the root
native view, so reliable tap-to-open is still tracked by #21 — but
visible, selectable, copyable link text strictly beats an invisible one.

Refs #1253

Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:29:59 +08:00
Daniel Salmerón Amselem
632c48fde3 Add timezone-aware cron schedules (#1232)
* feat: add timezone-aware cron schedules

* chore: normalize schedule timezone property

* fix: reject blank schedule timezone in mcp

* Stabilize terminal resize browser repro

* Fix terminal resize repro row assertions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-01 00:32:45 +08:00
Mohamed Boudra
922a93af2f Serialize browser app tests 2026-05-31 22:42:25 +07:00
Mohamed Boudra
2227540fad Show context usage beside compact mode selector 2026-05-31 22:38:46 +07:00
Mohamed Boudra
360110accc Stabilize terminal resize browser repro 2026-05-31 22:27:07 +07:00
Mohamed Boudra
6260224514 Update changelog for 0.1.88 2026-05-31 22:05:25 +07:00
Mohamed Boudra
1821f2853a Fix release prep checks 2026-05-31 22:05:18 +07:00
paseo-ai[bot]
c60bc9f5ae fix: update lockfile signatures and Nix hash [skip ci] 2026-05-31 14:45:49 +00:00
Mohamed Boudra
9f88b4e69c Refine appearance settings and syntax highlighting
Keep parsed chat and tool detail content in sync with appearance token changes. Hide font-family controls on native, use One as the default syntax theme, add Swift highlighting, and give Nord a distinct light palette.
2026-05-31 21:40:38 +07:00
Mohamed Boudra
4cc72a84e8 Fix terminal snapshots reflowing after resize
Terminal snapshots now carry soft-wrap row metadata only to clients that advertise support, so restored output can resize like live output without breaking older clients.
2026-05-31 19:40:15 +07:00
Mohamed Boudra
794cbcef63 Fix scripts menu resizing after service launch
Reanimated entering animations can leave the measured menu surface with an inline height. Release that fixed height after opening and when measured content changes so service rows can grow in place.
2026-05-31 19:40:15 +07:00
Mohamed Boudra
a1c8e1a1f9 Add appearance settings for theme, fonts, and syntax highlighting (#1236)
* Add appearance settings for theme, fonts, and syntax highlighting

New Appearance section in Settings to configure the app theme, UI and
mono font family + size, and a syntax-highlighting theme chosen
independently of light/dark. Font family fields default to empty and
show the active system stack as the placeholder; a live split-diff
preview reflects the choices as they change. The theme picker moves
here from General.

Preferences are client-only (AsyncStorage) and applied at runtime by
patching every registered Unistyles theme via
UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint
with no per-component preference reads and no useUnistyles. No
daemon/protocol change.

* Expand syntax themes and move the preview under the chooser

Replace the small fixed syntax-theme set with eight popular themes — GitHub,
Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a
compact per-theme role palette. The app theme and syntax theme stay separate;
the only coupling is the light/dark axis: a theme with both variants uses its
light palette on a light app and its dark palette on a dark app, while the code
frame (gutter, line numbers, background) keeps following the app theme. Default
is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match).

Move the live preview directly under the syntax-theme chooser, drop the
"preview.ts" filename header, and use a clearer code snippet for it.

* Update settings e2e for the moved theme picker and Appearance section

The theme picker moved from General to the new Appearance section, so the
General-content assertion no longer finds "Theme" — point it at "Default send"
(which stays in General) and add Appearance to the e2e section map plus an
expectAppearanceContent check, and exercise the Appearance section in the
sidebar-navigation test.

* Render the appearance preview as a unified diff

Switch the live preview from side-by-side columns to a single unified diff:
unchanged context lines plus "-"/"+" rows for the change, matching how diffs
read elsewhere in the app. Same snippet, mono font, syntax colors, and live
code-font draft behavior.

* Fix code font size change shrinking all UI text

applyAppearance scaled the live (already-patched) theme ramp instead of the
authored FONT_SIZE ramp, so it was not idempotent: every appearance change
re-scaled the current sizes, compounding the whole-UI font scale. With any
non-default UI size, changing the code size — or any setting — shrank (or grew)
all app text, and it persisted into the theme. Build the ramp from the canonical
FONT_SIZE ramp each apply (code size still set absolutely), so applies are
idempotent and a code-size change never touches the UI ramp.

* Apply the interface font across the app on web

react-native-web stamps a default font onto every text element, so the interface
font only reached the few components that set a font explicitly. Inject one rule
that points all text at a CSS variable (updated live as the setting changes),
with high specificity (1,2,0) so it deterministically beats RN-web's base font
and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and
monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded),
so they keep their code font.

Web-only: React Native has no global font cascade, so native still applies the UI
font only where components opt in. Inline `code` within chat prose is a known
minor gap (its render paths aren't tagged yet).

* Keep preview code in the code font

* Apply code font settings to terminals

* Fix settings host picker import after rebase

* Format terminal webview bundle
2026-05-31 16:09:01 +08:00
Mohamed Boudra
f26a81798b Make workspace tab switching faster (#1251)
* Avoid workspace shell renders when toggling sidebar

* Avoid rerendering inactive workspace screens

* Avoid rerendering unchanged sidebar rows

* Keep sidebar selection state in the list

* Speed up workspace tab switching
2026-05-31 15:46:17 +08:00
Mohamed Boudra
67133e3ebc Fix terminal resize after splitting panes (#1249) 2026-05-31 15:42:39 +08:00
Mohamed Boudra
d1f37b5d72 Route real provider tests through OpenRouter 2026-05-31 13:57:17 +07:00
Mohamed Boudra
8cf80c2486 Mark oversized generated diffs as too large
Gate tracked diff sections by content size before parsing and highlighting while preserving the existing batched git diff path.
2026-05-31 13:57:17 +07:00
Mohamed Boudra
3d96ea6354 Fix chat history paging around tool updates (#1247)
* Fix projected timeline paging

* Avoid full timeline fetches for plain pages
2026-05-31 05:38:29 +00:00
Mohamed Boudra
0e3eb9cf75 Flatten settings sidebar with a host picker (#1241)
* Flatten settings sidebar with a host picker

Settings had two implicit tiers: app-level sections as flat sidebar rows,
and per-host settings hidden behind a drill-in to one monolithic host page.
This flattens both into a single sidebar with an "App" group and a "Host"
group, where the host's settings are top-level rows scoped by a host picker.

- Split the monolithic HostPage into four section pages: Connections,
  Orchestration (was the unnamed inject-tools + system-prompt block),
  Providers, and Daemon (status, updates, restart, remove).
- Host picker reuses the canonical <Combobox> sidebar host switcher; defaults
  to the local host so a single-local-host user never thinks about "the daemon".
- Host sections become real sub-routes (/settings/hosts/[id]/[section]); the
  old /settings/hosts/[id] redirects to the connections section, so existing
  deep links (pairing, workspace, open-project) keep working.
- "Open providers" from the project screen now lands on the Providers section.

* Fix e2e host-settings navigation for the flat sidebar

The flat-settings refactor moved host settings onto separate section pages,
but three e2e helpers still expected the old monolithic host page:

- openDesktopSettings: the daemon-lifecycle card now lives on the Daemon
  section — navigate there before asserting it.
- acp catalog: "Add provider" now lives on the Providers section — open that
  section before opening the add-provider modal.
- openAddHostFlow: "Add host" is now an item inside the host picker (Combobox),
  not a standalone button — open the picker first.

Test-only; no product changes. Fixes the 8 playwright failures on this branch.
2026-05-31 12:55:08 +08:00
Mohamed Boudra
c8ca503cc3 Show agent terminals created in workspace subdirectories (#1245)
Terminals were bucketed and looked up by exact cwd, so a terminal an
agent opened in a subdirectory of the workspace never appeared in the
workspace panel and its live updates were dropped — the panel only ever
queried and subscribed the exact root.

getTerminals(root) now aggregates every bucket at or below the root, and
the session controller delivers a subdirectory change to each subscribed
root above it, keyed by that root and carrying the full aggregated list.
Keying by the root with the complete set is what keeps the client's
cache replacement from dropping the terminals that live at the root.
2026-05-31 12:29:05 +08:00
Mohamed Boudra
41cb1af036 Add handle pan-down fallback to bottom-sheet reopen e2e
The "model selector can open, close, reopen, and close again" test still flaked
on loaded CI runners even after re-tapping the backdrop: the model-selector
sheet re-renders as its model list settles, and Gorhom drops backdrop presses
during that churn, so the tap never dismisses within the retry window.

Fall back to dragging the sheet handle down, which drives Gorhom's pan-to-close
directly and is unaffected by the backdrop churn (mirrors the resilient close in
e2e/helpers/app.ts). The backdrop tap remains the primary path, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged. Verified locally by forcing the fallback (backdrop tap
disabled): the handle drag closes the sheet on its own.
2026-05-31 08:38:46 +07:00
Mohamed Boudra
17aa957d3e Stabilize bottom-sheet reopen e2e by retrying the backdrop tap (#1238)
The "model selector can open, close, reopen, and close again" test fails on
loaded CI runners: a single backdrop tap that lands while the sheet is still
present-animating is dropped (Gorhom ignores backdrop presses until the sheet
settles at its snap point), so the sheet never dismisses and the backdrop stays
visible. The reopen behaviour itself is correct — the visibility tracker is
unit-tested and the spec passes locally (3x), so this is test-interaction
fragility, not a product regression.

Re-tap the backdrop until the sheet dismisses, mirroring the resilient close
loop already used in e2e/helpers/app.ts. It stays a pure backdrop dismissal (no
Escape/pan fallback) so the real close path is still exercised, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged.
2026-05-30 22:59:02 +08:00
paseo-ai[bot]
2c756e9a8a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-30 02:53:33 +00:00
Mohamed Boudra
bb6a4db6e0 chore(release): cut 0.1.87 2026-05-30 09:49:45 +07:00
Mohamed Boudra
2a13a082b7 Changelog for 0.1.87 2026-05-30 09:48:43 +07:00
Mohamed Boudra
eea5932a21 Fix mobile bottom sheets reopening after dismiss 2026-05-30 09:43:49 +07:00
paseo-ai[bot]
e04bb942e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 16:05:39 +00:00
Mohamed Boudra
3c3574d670 Fix intermittent Android crash by upgrading Reanimated to 4.3.1
Reanimated 4.1.x crashes on Android with a NullPointerException in
ViewGroup.dispatchDraw (ReactViewGroup.dispatchDraw) — a child view is
null mid-draw because Reanimated commits a ShadowTree change during the
Android draw pass, mutating the view hierarchy while it's being painted.
Fixed upstream in 4.3.0 (software-mansion/react-native-reanimated#9072).

4.3.x requires react-native-worklets 0.8.x, so worklets moves 0.5.1 ->
0.8.3 in lockstep. react-native is pinned to exact 0.81.5 so the
re-resolve can't pull 0.81.6 (which needs react ^19.1.4, conflicting with
the react 19.1.0 override), and reanimated/worklets are added to root
overrides to force a single version across the tree — two copies of the
worklets runtime throw a version-mismatch at startup.
2026-05-29 23:01:07 +07:00
Mohamed Boudra
44863ec1dd Forward subagent permission requests 2026-05-29 22:02:18 +07:00
Mohamed Boudra
47414abc5e website tweaks 2026-05-29 19:33:57 +07:00
paseo-ai[bot]
9860dd36ef fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 11:50:20 +00:00
Mohamed Boudra
9e0c9d0662 chore(release): cut 0.1.86 2026-05-29 18:46:22 +07:00
Mohamed Boudra
654ebadaf5 Add 0.1.86 changelog entry 2026-05-29 18:27:57 +07:00
Mohamed Boudra
c2b4f3aa18 Add projectIconToDataUri to the projects-screen test mock 2026-05-29 18:27:48 +07:00
Mohamed Boudra
64e3a7152d Publish Electron migration post 2026-05-29 18:27:35 +07:00
Mohamed Boudra
daae71d1a8 Add Grok to the website and supported-providers docs
Adds the /grok SEO landing page (and its auto-generated route/sitemap
entries), the /agents directory card, and the Grok line in the ACP
catalog docs, following the Grok Build provider added to the in-app
catalog.
2026-05-29 18:10:51 +07:00
Mohamed Boudra
c1453b25a7 Add Claude fast mode toggle 2026-05-29 18:01:56 +07:00
Mohamed Boudra
c47f3190d8 Add Grok Build to the ACP provider catalog
Verified the ACP entrypoint against a real install (grok 0.2.11): the
command is `grok agent stdio`, and an initialize handshake returns a
valid ACP response (protocolVersion 1, model grok-build). Running
prompts requires a SuperGrok or X Premium+ login.
2026-05-29 17:59:03 +07:00
Mohamed Boudra
0803d5ff41 Show model selector provider errors 2026-05-29 17:16:35 +07:00
Mohamed Boudra
7ed0ce97df Fix local speech worker audio transport
Worker IPC now carries plain byte payloads instead of Node Buffer objects, so provider code receives process-local Buffers instead of transport-shaped views.
2026-05-29 17:09:01 +07:00
Mohamed Boudra
fcd93a23ac Add metadata generation page to public docs 2026-05-29 15:57:58 +07:00
Mohamed Boudra
ef3b8a42fd Merge branch 'main' of github.com:getpaseo/paseo 2026-05-29 14:16:19 +07:00
Mohamed Boudra
8b1bb90ce3 Show colored icons for projects without an icon
The letter-on-grey placeholder is replaced by a fill whose color is a
deterministic hash of the project key, so each project keeps a stable
color across the sidebar, projects list, and settings header. Mid-
saturation tones keep the white letter legible in both themes.
2026-05-29 14:06:47 +07:00
Mohamed Boudra
524aef5d75 Add Parakeet v3 local STT model for multilingual dictation
v2 is English-only; v3 covers 25 European languages with automatic
language detection. The archive shares v2's filenames, architecture,
sample rate, and feature dim, so no runtime loader changes are needed.

Document that the `language` config field and PASEO_*_LANGUAGE vars
only steer the OpenAI STT provider — the local Parakeet models ignore
them, so multilingual local dictation means selecting the v3 model.
2026-05-29 14:06:18 +07:00
Mohamed Boudra
69d36c76fe Show connected host daemon versions on the About page (#1220)
* Show connected host daemon versions on the About page

* Update About E2E assertion for the App version label
2026-05-29 14:51:53 +08:00
Mohamed Boudra
38f0560f9b Syntax-highlight Edit, Write, and Read tool calls (#1218)
Highlighting reuses the existing code-block tokenizer and LRU cache, runs
lazily on expand, and falls back to plain text above a size cap so a large
Read can't stall the main thread. The diff highlighter reconstructs the old
and new file text by position rather than from @@ line ranges, so diffs that
arrive without real hunk headers still highlight.

Read content is normalized server-side: the cat -n line-number gutter some
providers emit is stripped so content is uniformly raw source, with the first
line number surfaced as offset for the client to rebuild the gutter.
2026-05-29 14:51:14 +08:00
Matt Cowger
25d6fc8515 Prefer configured provider fallbacks for metadata generation (#1219)
* Prefer configured provider fallbacks for metadata generation

* Update client config test for metadata defaults
2026-05-29 06:38:22 +00:00
Mohamed Boudra
d1eb976653 Fix duplicate /clear in the slash command menu
A client command and a provider command can share a name (e.g. clear).
Both produced the same option id, which became a duplicate React key —
React then left stale rows and scrambled ordering, so /clear showed
twice and ranking broke as the query narrowed. Drop the colliding
provider command and keep the client one, which has extra affordances.
2026-05-29 13:23:29 +07:00
Mohamed Boudra
14d176a4e2 Add manual refresh button to git diff controls (#1216)
* feat(git): add manual refresh button to diff controls

Adds a checkout_refresh RPC that forces a hard re-read of the git and
GitHub snapshot plus the diff, bypassing polling. The diff controls now
show a refresh button (feature-gated) that triggers it and surfaces
errors via a toast — an escape hatch when the polled state goes stale.

* refactor(git): address review — dotted RPC name + withUnistyles

- Rename checkout_refresh RPC to the dotted convention
  (checkout.refresh.request/response) per docs/rpc-namespacing.md.
- Replace the banned useUnistyles() call in DiffRefreshButton with
  withUnistyles wrappers for the icon/spinner color, per docs/unistyles.md.

* refactor(git): move refresh into the checkout actions store

Per the feature audit: the manual refresh was the one checkout action
whose UI logic lived inline in the component instead of alongside its
siblings (commit/pull/push) in useCheckoutGitActionsStore. Move it into
the store so there's one home for "UI triggers a checkout RPC" — it now
reuses runCheckoutAction's pending/success status and query invalidation.
The component just reads status and shows an error toast.

* fix(git): size refresh loader to the icon to stop layout shift

ActivityIndicator size="small" renders ~20px regardless of platform,
wider than the 14px refresh icon, so swapping to it grew the button and
shifted the controls row. Use SyncedLoader at the same iconSize — it
renders into a size×size box, so both states share one footprint.

* fix(git): match file-explorer refresh control (RotateCw + LoadingSpinner)

Mirror the file-explorer pane's refresh button exactly: RotateCw icon,
LoadingSpinner while refreshing, both centered in a fixed icon-sized box
so the spinner can't grow the control or shift the row. Replaces the
ad-hoc RefreshCw + SyncedLoader pairing.
2026-05-29 14:23:16 +08:00
Mohamed Boudra
04fb0f9b82 Keep local voice memory out of the daemon (#1217)
* Move local speech work out of daemon

Run local speech models in a lazily spawned worker so native model memory and blocking synthesis/transcription work stay out of the daemon process. The worker exits after idle time so local speech RSS can be reclaimed.

* Update supervisor stop test for process title
2026-05-29 13:27:28 +08:00
Mohamed Boudra
cdcf3d8d55 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-29 12:20:59 +07:00
Mohamed Boudra
cfc9665f6a Fix wrong local speech STT model ID in voice docs
The documented parakeet-tdt-0.6b-v3-int8 doesn't exist in the model
catalog; only the v2 variant is accepted. Copying the doc value into
config produced an "invalid model id" error on daemon restart.
2026-05-29 12:20:22 +07:00
Mohamed Boudra
1350167dda Push the whole composer up with the keyboard on mobile
The keyboard shift was applied only to the input box inside the
composer, so the subagents track and the draft import-session pill —
rendered as siblings above the composer — stayed behind the keyboard.
Lift the shift to the wrapper that holds the full composer column and
add an opt-out prop so the composer skips its own shift when a parent
owns it.
2026-05-29 12:20:00 +07:00
Mohamed Boudra
bb2f540f29 Widen the mobile composer relative to the message column 2026-05-29 12:00:50 +07:00
Mohamed Boudra
f861aa2e88 Tighten mobile composer footer glyph alignment 2026-05-29 11:28:14 +07:00
Mohamed Boudra
3585c80266 Allow previews to open readable files (#1214)
* Allow previews to open readable files

* Fix Windows file preview path test

* Address file preview review comments
2026-05-29 11:35:38 +08:00
Mohamed Boudra
0ee76b2ea0 Cover timeline epoch reset behavior 2026-05-29 09:55:40 +07:00
Mohamed Boudra
65ace20880 Fix mobile agent timeline catch-up 2026-05-29 09:31:16 +07:00
Mohamed Boudra
2e1b907c63 Improve Playwright E2E test quality (#1210)
* chore(e2e): begin Playwright test-quality pass

* test(e2e): migrate terminal-performance spec onto TerminalE2EHarness

Replace the hand-rolled daemon-client lifecycle (connectTerminalClient +
openProject + createTerminal/navigateToTerminal/killTerminal) with the shared
TerminalE2EHarness, matching terminal-keystroke-stress.spec.ts. The spec now
seeds the workspace and terminals through the harness vocabulary instead of
duplicating setup, cutting the file by ~40 lines with no behavior change.

* test(e2e): extract shared mock-agent workspace helper

Five specs reimplemented the same setup: seed a temp repo, open it as a
project, create a mock-provider agent, then navigate to its workspace route.
Extract seedMockAgentWorkspace + openAgentRoute into helpers/mock-agent.ts and
migrate the rewind-menu and user-message-contract UI-contract specs onto them,
dropping their local getServerId/openAgent/inline-seed duplication.

* test(e2e): migrate terminal tab rename spec onto TerminalE2EHarness

Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createTerminal + navigateToTerminal + manual cleanup) with the
shared TerminalE2EHarness and withTerminalInApp helpers, matching the other
terminal specs. Behavior and assertions are unchanged.

* test(e2e): share openProjectViaDaemon across sidebar specs

Both sidebar-workspace and sidebar-workspace-rename rolled their own
identical openProjectViaDaemon helper against the workspace-setup daemon
client. Promote a single shared helper into helpers/workspace-setup.ts
(returning id/name/workspaceDirectory) and have seedProjectForWorkspaceSetup
delegate to it, removing the duplicated open-project logic.

* test(e2e): migrate client-slash-commands spec onto shared mock-agent helper

Replace the spec's hand-rolled openProject/createReadyMockAgent/
openActiveAgentTab/getServerId setup with seedMockAgentWorkspace and
openAgentRoute from helpers/mock-agent, matching the rewind-menu and
user-message-contract specs. Drops ~60 lines of duplicated daemon-client
wiring; the test bodies now read as domain intent.

* test(e2e): converge remaining workspace-setup specs onto shared daemon helpers

Replace the last inline openProject + null-check blocks with the shared
openProjectViaDaemon helper in the file-explorer-collapse and pr-pane specs,
and route workspace-setup-runtime project registration through
seedProjectForWorkspaceSetup. Behavior is identical; the seeding vocabulary now
lives entirely in helpers/workspace-setup.ts.

* test(e2e): migrate composer-autocomplete spec onto shared mock-agent helper

Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createAgent + route building + bespoke cleanup) with
seedMockAgentWorkspace + openAgentRoute from helpers/mock-agent, matching the
client-slash-commands migration. Drops the local getServerId, openProject, and
cleanupWithin helpers.

* test(e2e): migrate codex-plan-approval spec onto shared mock-agent helper

Replace the hand-rolled daemon client, project open, agent creation, and
manual route building with seedMockAgentWorkspace + openAgentRoute. The spec
now reads as intent: seed a mock agent, open it, approve the plan, assert the
panel clears.

* test(e2e): converge daemon-client bootstrap onto one shared factory

The five e2e helpers each rolled their own daemon-client connect logic —
duplicating the ws-url resolution, node WebSocket factory, client
construction, and connect call, plus a redeclared config interface.

Extract a single connectDaemonClient factory in daemon-client-loader.ts
that each helper delegates to with its own typed client interface. This
also propagates the port-6767 safety guard (previously only in two
helpers) to all of them, so no test client can target the developer
daemon. Specs are untouched; behavior is preserved.

* test(e2e): promote shared daemon seed client out of terminal-perf helper

The general-purpose E2E daemon client (workspace/agent/terminal seeding and
driving) had grown inside terminal-perf.ts under the name
TerminalPerfDaemonClient, even though most consumers — mock-agent, composer,
rewind-flow, and the launcher/title-handoff specs — have nothing to do with
terminal performance. Move the interface and its connect factory into a
neutrally-named helpers/seed-client.ts (SeedDaemonClient / connectSeedClient)
and point every consumer at it. terminal-perf.ts keeps only its terminal page
helpers. Pure rename/relocate; no behavior change.

* test(e2e): converge E2E_SERVER_ID lookup onto one shared helper

The server-id env accessor was copy-pasted as a local `getServerId`
function in six helpers and five specs (plus a `requireServerId` twin in
the sidebar helper). Extract a single `helpers/server-id.ts` accessor and
route every caller through it, so a new spec imports the vocabulary
instead of re-deriving it. Pure refactor — identical lookup behavior.

* test(e2e): finish converging server-id lookup onto the shared helper

The previous pass extracted helpers/server-id.ts but only routed callers
that wrapped the env read in a local function. Eight specs still re-derived
the lookup inline — some as their own copy-pasted getSeededServerId/
getE2EServerId functions, some as bare `process.env.E2E_SERVER_ID` blocks
inside test bodies. Route them all through getServerId() so a new spec
imports the vocabulary instead of re-deriving it. Pure refactor — identical
lookup behavior; daemon-port reads are left untouched.

* test(e2e): converge workspace seeding onto a shared seedWorkspace helper

Extract the repeated temp-repo + seed-client + openProject bootstrap into
seedWorkspace() in helpers/seed-client.ts, returning a {client, repoPath,
workspaceId, cleanup} handle. seedMockAgentWorkspace and the agent-title-handoff
spec now build on it instead of re-rolling the trio, so the open-project error
handling and teardown live in one place.

* test(e2e): converge launcher-tab spec onto the shared seedWorkspace helper

Replace the hand-rolled createTempGitRepo + connectSeedClient + openProject
trio in beforeAll with seedWorkspace(), and drop the redundant second seed
client in the terminal-title block in favor of the shared workspace.client.

* test(e2e): converge file-explorer-collapse onto the shared seedWorkspace helper

Teach seedWorkspace to forward createTempGitRepo options (files/branches/
remote) so file-seeding specs can drop their hand-rolled
createTempGitRepo + connect + openProjectViaDaemon trio. Migrate
file-explorer-collapse onto it, removing its bespoke WorkspaceSetup client setup.

* test(e2e): converge non-git project setup onto a shared workspace helper

Promote the non-git createTempDirectory out of sidebar-workspace.spec.ts
into helpers/workspace.ts next to createTempGitRepo, and dedupe the
copy-pasted temp-root resolution (workspace.ts, with-workspace.ts, and the
spec) into one shared resolveTempRoot(). The spec drops its low-level
node:fs imports and reads in domain terms.

* test(e2e): converge agent-tab-rename spec onto the shared seedWorkspace helper

Drop the bespoke daemon-client + temp-repo + manual-cleanup trio in
workspace-agent-tab-rename and seed through seedWorkspace, matching the
other converged specs. createIdleAgent now takes a minimal structural
client interface so it accepts either the archive-tab client or the
shared seed client (type-only; the existing archive-tab callers are
unchanged). Verified by running the spec on Desktop Chrome.

* test(e2e): converge pane-remount spec onto the shared seedWorkspace helper

Drop the bespoke archive-tab daemon client + temp-repo + manual-cleanup
trio in workspace-pane-remount and seed through seedWorkspace, matching
the other converged specs. createIdleAgent already accepts the shared
seed client structurally, so the only behavior change is teardown now
goes through workspace.cleanup(). Verified by running the spec on
Desktop Chrome.

* test(e2e): converge sidebar-workspace-rename onto the shared seedWorkspace helper

Expose workspaceName and workspaceDirectory on SeededWorkspace (sourced from
the open-project response) so branch-rename specs can read the resolved branch
name and checkout directory without a bespoke client. Migrate
sidebar-workspace-rename off its hand-rolled connectWorkspaceSetupClient +
createTempGitRepo + openProjectViaDaemon trio, dropping the per-test
client/repo cleanup in favor of seedWorkspace's single cleanup handle.

* test(e2e): converge sidebar-workspace list onto the shared seedWorkspace helper

Migrate all five "Sidebar workspace list" tests off their hand-rolled
connectWorkspaceSetupClient + createTempGitRepo/createTempDirectory +
openProjectViaDaemon trio onto seedWorkspace, collapsing each test's
client/repo cleanup into the single seedWorkspace cleanup handle and dropping
the spec-local setGitHubRemote/execSync machinery.

Two small helper additions make the full file converge:

- seedWorkspace gains a `git: false` option that seeds a plain non-git
  directory (via createTempDirectory) instead of a git repo, so the non-git
  project test gets the same single-handle treatment.
- createTempGitRepo's configureRemote now relabels origin to a display URL
  when both `withRemote` and `originUrl` are given: it sets up the local
  tracking remote, pushes, then `git remote set-url` to the GitHub URL. This
  reproduces the prior withRemote + setGitHubRemote git state exactly (real
  local tracking refs, GitHub origin URL for project grouping) in one fixture
  call, so the GitHub-remote tests are behavior-preserving.

* test(e2e): converge projects-settings onto the shared seedWorkspace helper

Replace the per-fixture connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio with seedWorkspace(), and expose the daemon's
projectId/projectDisplayName on SeededWorkspace so fixtures can read the
project label directly. All 9 specs pass.

* test(e2e): converge composer-attachments onto the shared seedWorkspace helper

Replace the inline connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio in the "composer is locked while new workspace agent
is being created" test with a single seedWorkspace() call, dropping the manual
client.close()/repo.cleanup() teardown in favor of workspace.cleanup(). The
test still passes against a real daemon.

* test(e2e): converge settings-toggle-tab-regression onto the shared seedWorkspace helper

Both tests rolled their own daemon client + temp git repo + manual agent
archive cleanup. Replace that trio with seedWorkspace(), drive idle agents
through workspace.client, and route off workspace.repoPath, leaving cleanup
to workspace.cleanup(). Matches the pattern already used by
workspace-pane-remount and workspace-agent-tab-rename.

* test(e2e): converge workspace-navigation-regression onto the shared seedWorkspace helper

Replace the bespoke connect/openProject/createTempGitRepo/archive trios in
the reconnect, cold-URL, and sidebar-navigation tests with seedWorkspace(),
matching the other migrated specs. Cleanup collapses to workspace.cleanup().

* test(e2e): drop orphaned dead code from agent-bottom-anchor helper

The agent-bottom-anchor spec was removed in a prior cleanup, but its
helper kept a private daemon-client interface and connect fn (duplicating
the shared seed client), seedBottomAnchorAgent, the reply-message builders,
and several scroll helpers that no spec references anymore. Only
readScrollMetrics, expectNearBottom, and waitForContentGrowth are still
used (by agent-stream.ts); keep those and delete the rest.

* test(e2e): converge archive-tab daemon client onto the shared seed client

Fold archiveAgent and fetchAgentHistory into the canonical SeedDaemonClient
and route the archive-tab and sessions-empty specs through connectSeedClient,
deleting the bespoke ArchiveTabDaemonClient wrapper. Both specs only need
general-purpose agent seed/drive operations, so they now share one client
interface instead of re-declaring their own.

* test(e2e): derive workspace-setup daemon client from the real client type

Replace the hand-rolled WorkspaceSetupDaemonClient interface with a
Pick<InternalDaemonClient, ...> over the real daemon client, matching the
pattern already used by the new-workspace helper. The 45-line re-declaration
of RPC method signatures could silently drift from the protocol; deriving it
from the source of truth keeps the test client honest and shrinks the helper.

* test(e2e): converge duplicated escapeRegex onto one shared helper

Seven byte-identical copies of escapeRegex lived across three specs and four
helpers. Extract a single helpers/regex.ts and route every caller through it,
so the suite has one regex-escaping primitive instead of re-declaring the same
pure function per file.

* test(e2e): converge E2E_DAEMON_PORT resolution onto one shared accessor

The isolated test daemon's port was re-read from the environment in ~10
places — three local helper functions in specs, two inline blocks, and
several helper modules — each repeating the "throw if unset" check and,
in the safety-critical paths, the "refuse the developer daemon (6767)"
guard.

Add helpers/daemon-port.ts exporting getE2EDaemonPort(), mirroring
getServerId(), and route every reader through it. The 6767 guard now
applies everywhere: the test port is never legitimately 6767, so
refusing it uniformly keeps every spec off the developer daemon.

While here, route the two inline port-regex escapes through the existing
escapeRegex helper instead of hand-inlining the same pattern.

* test(e2e): capture create-agent cwd via shared WS-frame helper

workspace-cwd's draft-agent test rolled its own request recorder by
monkeypatching WebSocket.prototype.send and stashing frames on a
window global, then reading them back through page.evaluate. Replace
that with captureWsSessionFrames — the same outbound-frame helper four
other specs already use for create_agent_request — so the assertion
reads the cwd directly and the spec drops the browser-side internals
reach-around. No behavior change; the three cwd cases still pass.

* test(e2e): converge daemon WS-route regex onto one shared helper

Five sites rebuilt the Playwright routeWebSocket matcher for the E2E
daemon inline as `new RegExp(`:${escapeRegex(getE2EDaemonPort())}\b`)`
(new-workspace, project-settings, composer-autocomplete, and two in
workspace-navigation-regression), and startup-dsl rolled the same regex
for arbitrary blocked test-host ports. Add daemonWsRoutePattern() and
wsRoutePatternForPort(port) to daemon-port.ts — whose docstring already
promised route patterns live here — and point every site at them.

The emitted regex is byte-identical, so interception behavior is
unchanged; composer-autocomplete still passes. Drops the now-unused
escapeRegex/getE2EDaemonPort imports and the redundant daemonPort locals.
2026-05-29 09:46:15 +08:00
paseo-ai[bot]
23502e474d fix: update lockfile signatures and Nix hash [skip ci] 2026-05-28 17:14:00 +00:00
Mohamed Boudra
b0a0cb4a99 chore(release): cut 0.1.85 2026-05-29 00:09:30 +07:00
Mohamed Boudra
342e92d0c7 Update changelog for 0.1.85 2026-05-29 00:06:48 +07:00
Mohamed Boudra
0170eba233 Add Opus 4.8 to the Claude model picker
Opus 4.8 (and its 1M-context variant) become the default Claude
models, pushing Opus 4.6 off the default slot. Opus 4.7 stays in
the list as the previous release.
2026-05-29 00:03:39 +07:00
Mohamed Boudra
b6103a59da Archive agents on worktree archive; clean up schedules on archive (#1206)
* Archive agents on worktree archive; clean up schedules on archive

Worktree archive used to hard-delete every agent inside it (storage
removed, agent_deleted emitted), losing history along with the worktree.
It now archives those agents instead, so they remain in storage with an
archivedAt timestamp and stay visible in the archived list.

Schedules targeting an archived agent were left as dead records that the
schedule service rejected at run time. AgentManager now fires an
onAgentArchived callback from every archive path (live, stored snapshot,
cascade-to-children), and bootstrap wires it to
ScheduleService.deleteForAgent so those schedules are cleaned up
automatically.

* Push agent_state when archiving stored-only agents; tolerate schedule delete errors

Greptile review on PR #1206 flagged two issues:

1. archiveSnapshot persisted archivedAt to storage but did not emit an
   agent_state event for stored-only agents. Connected clients would
   keep showing them as active until a full reload. Mirror the dispatch
   logic from markRecordArchived: notifyAgentState for live agents,
   dispatchArchivedStoredAgent for off-memory non-internal agents.

2. ScheduleService.deleteForAgent used Promise.all, which short-circuits
   on the first store error and abandons the rest. Switch to
   Promise.allSettled, count fulfilled deletions, and log rejections.

* Tighten agent-manager archive tests after /audit-tests review

- Split "fires onAgentArchived for live, stored, and cascaded archives"
  into two single-behavior tests with independent fixtures, so the assertion
  array isn't reset mid-test.
- Strict equality on the cascade hook assertion (no more arrayContaining).
- "archiveSnapshot dispatches archived state" now captures the full agent
  state event, asserts a dispatch happened, asserts the dispatched agent
  id, and asserts the lifecycle is closed.
2026-05-28 23:02:58 +08:00
Mohamed Boudra
24526a3b23 Move branch-slug to @getpaseo/protocol
The app must not import @getpaseo/server. Moving the slug validation
and slugify helpers into @getpaseo/protocol (already a shared dep of
app, client, server) gives every package a legitimate home for the
import. Drops the corresponding ./utils/branch-slug subpath from
@getpaseo/server's exports map.

Unblocks the Deploy App workflow, which only builds protocol/client/
audio for the app — server's dist never exists in that pipeline.
2026-05-28 18:41:13 +07:00
paseo-ai[bot]
6d1aa1f415 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-28 11:15:56 +00:00
Mohamed Boudra
4b973ae9ec chore(release): cut 0.1.84 2026-05-28 18:11:58 +07:00
Mohamed Boudra
e02e8426d4 Update changelog for 0.1.84 2026-05-28 18:10:30 +07:00
Mohamed Boudra
e4188f5222 Document the two-step release flow
The release has two steps: preparation, which the agent does locally and
reversibly, and go-ahead, which only the user authorizes. Last-minute
changes always need approval, code changes never bundle into the
changelog or release commit, and a sanity-check finding is information
for the user — not a directive for the agent to act on.
2026-05-28 18:08:54 +07:00
Yurui Zhou
8262fb42af Fix/pi ask user submit (#1188)
* Fix Pi ask_user optional input handling

* Clarify Pi ask_user optional comment prompt

* Combine Pi ask_user optional comment UI
2026-05-28 19:08:38 +08:00
Mohamed Boudra
3176f844e7 Make MCP provider controls match the app (#1198)
* Align MCP provider controls with app provider state

* Deepen provider snapshot routing

* Make provider snapshots the daemon authority

* Move provider shutdown behind a generic AgentClient seam

OpenCodeServerManager is now owned entirely by the OpenCode provider.
ProviderSnapshotManager.shutdown() and provider-registry.shutdownProviders()
materialize enabled clients and call an optional shutdown() per client; the
OpenCode client forwards to its runtime. Other providers ignore it.

Also wires providerSnapshotManager into the remaining Session-constructing
tests that were missing it (server-tests CI failure).

* Use platform-native cwd in change-event test
2026-05-28 18:25:09 +08:00
Mohamed Boudra
8234ecb7ba Upload only mac DMGs as workflow artifacts 2026-05-28 16:20:58 +07:00
Mohamed Boudra
adb9a57cfc Build server before desktop app export 2026-05-28 15:50:43 +07:00
paseo-ai[bot]
93c14cb8ad fix: update lockfile signatures and Nix hash [skip ci] 2026-05-28 08:44:09 +00:00
Mohamed Boudra
a00152290f Upload desktop workflow artifacts 2026-05-28 15:40:37 +07:00
Mohamed Boudra
8e4cbf8ca6 Simplify local speech models 2026-05-28 15:40:37 +07:00
Mohamed Boudra
0737c5c973 Stop publishing package debug artifacts 2026-05-28 15:40:36 +07:00
Mohamed Boudra
c4f9874e1f Fix archive redirect route typing 2026-05-28 15:40:36 +07:00
Mohamed Boudra
990bca71b7 Polish blog post metadata 2026-05-28 15:40:36 +07:00
Mohamed Boudra
f431ebee6d Add Electron migration post 2026-05-28 15:40:36 +07:00
Mohamed Boudra
93189148f5 Fix mobile code block rendering 2026-05-28 15:40:36 +07:00
Mohamed Boudra
3baba543a4 Add opencode resume command 2026-05-28 15:40:36 +07:00
Mohamed Boudra
f20393dbb7 Bound workspace git service caches to fix daemon memory leak (#1200)
The seven Map-based caches in WorkspaceGitServiceImpl had no eviction —
the 15s TTL only marked entries stale before refetching in place. Long-
lived daemons accumulated entries for every ephemeral worktree cwd that
ever ran, with checkoutDiffCache holding multi-MB highlighted diffs.

Switch to LRUCache. checkoutDiffCache caps at 64 (heavy values); the
other six aux caches cap at 256. Stale-in-place TTL semantics are
unchanged.
2026-05-28 16:38:50 +08:00
Mohamed Boudra
3ac182cffc Improve app tests (#1197)
* docs(testing): require ports-and-adapters unit tests or real e2e — no in-between

State the end-state explicitly so the test-suite cleanup has a written bar.

* Make useProjects a ports-and-adapters unit test

Extract the per-host workspace aggregation out of useProjects into a
pure fetchAggregatedProjects(input) that takes a typed ProjectsRuntime
adapter. The hook becomes a thin useQuery shim.

The test loses jsdom, @testing-library/react, QueryClientProvider, and
all vi.mock/vi.hoisted of @/runtime/host-runtime. It now exercises the
real aggregation against an in-memory ProjectsRuntime adapter.

* Make useLoadOlderAgentHistory a ports-and-adapters unit test

Extract the load-older sequence into a pure async function that takes
its client, in-flight tracker, toast, and logger as injected
dependencies. The hook reads the session store at call time and wires
the real adapters in. Tests now drive the pure function with typed
fakes — no JSDOM, no @testing-library/react renderHook, no console
spy.

* Make useAgentHistory a ports-and-adapters unit test

Drop the dead __private__ namespace from use-agent-history.ts, expose
fetchAgentHistoryPage and an AgentHistoryClient port as normal exports.
The hook keeps its useInfiniteQuery wiring; the page fetcher is now
testable in isolation.

The test loses jsdom, @testing-library/react, QueryClientProvider, the
vi.hoisted/vi.mock of @/runtime/host-runtime, and the renderHook timing
loops. It exercises the page fetcher directly against an in-memory
AgentHistoryClient adapter that records each call.

* Make usePrPaneData a ports-and-adapters unit test

Extract the timeline fetch into a pure fetchPrPaneTimelinePage that
takes a PrPaneTimelineClient adapter and an UnsupportedTimelineRegistry
port. Extract the rest of the hook's wiring into pure exports:
extractPrRepoIdentity, shouldFetchTimelineFrom, selectPrPaneState. The
module-level unsupported-tuple Set becomes a swappable in-memory
registry; production still wires the same module-level default.

The test loses JSDOM, vi.hoisted + vi.mock of @/runtime/host-runtime,
QueryClientProvider, createRoot, act, focusManager/onlineManager
toggling, and a hand-rolled waitForExpectation polling loop. It drives
each pure function and the timeline fetcher directly against typed
fakes and asserts on recorded state.

* Make useProvidersSnapshot a ports-and-adapters unit test

Extract the network and cache work into pure functions taking a
typed ProvidersSnapshotClient adapter: fetchProvidersSnapshot,
refreshAndApplyProvidersSnapshot, applyProvidersSnapshotUpdate, and
selectorOpenRefetchDecision. The hook still drives them through
react-query and the host-runtime websocket subscription; production
wires the same DaemonClient as before.

The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, act, and a
hand-rolled listener-bucket trap that reached into the spied
client.on subscriber. It drives each pure function directly against
a typed FakeProvidersSnapshotClient and a real QueryClient.

* Make useSettings a ports-and-adapters unit test

Extract the pure load/save logic into use-settings.pure.ts taking a
typed SettingsDeps adapter: a KeyValueStorage port and a
DesktopSettingsBridge port. The hook file keeps the public 0-arg API
(loadAppSettingsFromStorage, loadSettingsFromStorage, persistAppSettings,
saveAppSettings) by wrapping the pure functions with productionDeps that
wire AsyncStorage, isElectronRuntime, loadDesktopSettings, and
migrateLegacyDesktopSettings as before.

The test loses three vi.mock blocks (async-storage, @/desktop/host,
@/desktop/settings/desktop-settings), two vi.hoisted blobs, and the
vi.resetModules + await import("./use-settings") per-test pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage and a FakeDesktopBridge that records applied
migrations, asserting on observable storage state rather than mock
function calls.

* Make useAgentCommandsQuery a ports-and-adapters unit test

Extract the daemon call into fetchAgentCommands, a pure async
function taking a typed AgentCommandsClient adapter. The hook still
wraps it in useQuery and resolves the host-runtime client; production
wires the same DaemonClient as before.

The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, and
waitFor. It calls fetchAgentCommands directly against a typed
FakeAgentCommandsClient.

* Make useChangesPreferences a ports-and-adapters unit test

Extract the AsyncStorage-touching load and save logic into
use-changes-preferences.pure.ts taking a typed KeyValueStorage adapter.
The hook file keeps the public API (useChangesPreferences,
loadChangesPreferencesFromStorage) by wrapping the pure functions with a
productionStorage wired to AsyncStorage as before.

The test loses vi.hoisted + vi.mock of
@react-native-async-storage/async-storage, vi.resetModules, and the
per-test dynamic await import("./use-changes-preferences") pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage, asserting on observable storage entries rather
than mock function calls. Adds coverage for saveChangesPreferences,
including the no-prior-cache fallback path.

* Make useArchiveAgent a ports-and-adapters unit test

Extract the queryClient-only pure helpers (toArchiveKey,
selectPendingArchiveAgentIds, setAgentArchiving, isAgentArchiving,
removeAgentFromListPayload, markAgentArchivedInHistoryPayload, and the
queryClient-mutating cache helpers) into use-archive-agent.pure.ts. The
hook file keeps the React surface (usePendingArchiveAgentIds,
useArchiveAgent, applyArchivedAgentCloseResults) by importing from the
pure module, and the __private__ reach-around export is gone.

The test loses @vitest-environment jsdom, @testing-library/react, the
renderHook/act/waitFor imports, and the __private__ reach-around. It
calls the pure helpers directly against a real QueryClient and the real
session store. The renderHook test of usePendingArchiveAgentIds is
dropped — it asserted on react-query's subscription mechanics rather
than on our logic.

* Make useSidebarWorkspacesList a ports-and-adapters unit test

Extract the pure pieces — types, applyStoredOrdering,
appendMissingOrderKeys, buildSidebarProjectsFromStructure, and the new
computeSidebarOrderUpdates + deriveSidebarLoadingState helpers — into
use-sidebar-workspaces-list.pure.ts. The hook reads its persistent
sidebar order via the pure helper inside useEffect and derives its
loading state via the pure helper, instead of inlining the logic across
two effects. createSidebarWorkspaceEntry stays in the hook file since
it pulls selectPrHintFromStatus, but the hook re-exports it for
existing callers.

The test loses @vitest-environment jsdom, react-dom/client,
@testing-library/react, and the three Probe components that mounted
React just to assert on effect mechanics. It calls
computeSidebarOrderUpdates and deriveSidebarLoadingState directly, with
no module mocks or hoisted globals. The "does not subscribe while
disabled" assertion is dropped — it was testing useSyncExternalStore's
subscribe gate, not our logic.

* Make useAgentInitialization a ports-and-adapters unit test

Extract ensureAgentIsInitialized and refreshAgent — the entire
imperative bodies of the hook's two callbacks — into
use-agent-initialization.pure.ts, taking setAgentInitializing as an
injected port. Add createSetAgentInitializing as a factory that binds
serverId to the zustand setInitializingAgents action. The hook itself
collapses to ~20 lines of useMemo + useCallback bindings.

The test drops @vitest-environment jsdom, @testing-library/react,
renderHook, and act. It calls ensureAgentIsInitialized and refreshAgent
directly with a bound setAgentInitializing fake. No React mounting, no
module mocks, no hoisted globals.

* Make useClientActivity a ports-and-adapters unit test

Extract the activity-tracker state machine — lastActivityAt bookkeeping,
heartbeat throttling, app-visibility transitions, system-idle monotonic
update, and focused-agent change handling — into
use-client-activity.pure.ts. createClientActivityTracker takes the
heartbeat client, deviceType, and a now() port; the hook wires DOM /
AppState / Electron-idle listeners to tracker methods.

The test drops @vitest-environment jsdom, react-dom/client mounting,
act, and four module mocks (@/constants/platform, @/desktop/electron/idle,
react-native, @getpaseo/client/internal/daemon-client). It calls the
tracker directly with a fake heartbeat client and a test clock; the
fake records emitted heartbeats as observable state.

* Make useHoverSafeZone a ports-and-adapters unit test

Extract the safe-zone state machine — wasInside dedupe, bridge-rect
geometry between trigger and content, and inside/outside transitions —
into use-hover-safe-zone.pure.ts. createHoverSafeZoneTracker takes
getTriggerRect / getContentRect / onEnterSafeZone / onLeaveSafeZone;
the hook just wires document pointermove, window pointerout, and
window blur listeners to tracker methods.

The test drops @vitest-environment jsdom, react-dom/client mounting,
act, @testing-library/react renderHook, the vi.mock("@/constants/platform")
shim, the IS_REACT_ACT_ENVIRONMENT stub, and the getBoundingClientRect
patching helper. It calls the tracker directly with fake rect getters
and asserts on recorded enter/leave counts.

* Make useArchiveSubagent a ports-and-adapters unit test

* Make openImagePathsWithDesktopDialog a ports-and-adapters unit test

Inject the DesktopDialogBridge into openImagePathsWithDesktopDialog instead
of reaching for getDesktopHost() inside the function. useImageAttachmentPicker
passes getDesktopHost()?.dialog at the call site; the native sibling matches
the new signature.

The test drops vi.mock("@/desktop/host", ...) and vi.hoisted() in favor of a
typed in-memory fake dialog that records the options it was called with. No
global module substitution, no spies — the test reads the fake's recorded
state.

* Make useIosHardwareKeyboardSubmit a ports-and-adapters unit test

* Make UpdateCalloutSource a ports-and-adapters unit test

Extract resolveUpdateCalloutDescriptor as a pure function that maps
updater state to a structured callout descriptor. UpdateCalloutSource
becomes a thin React shim that materializes the descriptor's icon and
description as ReactNodes before registering with the sidebar callout
API.

Replaces a 259-line JSDOM + react-dom/client test that mocked five
modules (unistyles theme, lucide icons, async-storage, openExternalUrl,
useDesktopAppUpdater) and mounted SidebarCalloutProvider/Slot just to
assert deterministic title/description/action/dismissal-key derivations.
The new test exercises the resolver directly with zero React, zero DOM,
zero mocks.

* Delete dead useWorkspaceNavigation hook + collapse re-export indirection

The hook had zero production callers — only its own test, which used
vi.hoisted + vi.mock + jsdom + @testing-library/react/renderHook to
verify a useCallback wrapper. The file also re-exported navigateToWorkspace
from the navigation store, so five production importers and two sibling
tests reached the store through a hook-module path that had nothing to
do with hooks.

Retargets every importer to @/stores/navigation-active-workspace-store
directly and removes the indirection module + its slop test.

* Make useCheckoutStatusQuery a ports-and-adapters unit test

Extract peekOrFetchCheckoutStatus and applyCheckoutStatusUpdate to a
sibling checkout-status-cache.ts so both pure functions operate on an
injected QueryClient and CheckoutStatusClient, with no React or host
runtime imports. useCheckoutStatusQuery becomes a thin shell that
composes useQuery + useEffect and delegates the cwd-filter + cache write
to applyCheckoutStatusUpdate.

Replaces a 318-line JSDOM + react-dom/client + fake-timers test that
mocked @/runtime/host-runtime via vi.hoisted, mounted a Probe component
to read the hook's data, and captured the subscription handler in a
hoisted Set. The new test exercises both functions directly against a
real QueryClient — zero React, zero DOM, zero mocks, zero fake timers.

* Make workspace-navigation a ports-and-adapters unit test

* Make navigateToAgent a ports-and-adapters unit test

* Make redirectIfArchivingActiveWorkspace a ports-and-adapters unit test

* Make openProjectDirectly a ports-and-adapters unit test

* Make navigation-active-workspace-store a ports-and-adapters unit test

* Make desktop-attachment-store a ports-and-adapters unit test

* Make readDesktopSystemIdleTimeMs a ports-and-adapters unit test

Rename getDesktopSystemIdleTimeMs to readDesktopSystemIdleTimeMs and
take the desktop IPC invoker as a parameter. The single caller in
use-client-activity.ts now imports invokeDesktopCommand directly and
passes it; the test wires a typed fake invoker.

Drops vi.mock("@/desktop/electron/invoke"), vi.hoisted, and the four
vi.spyOn(console, "warn") log assertions. Tests now assert the
documented behaviour (returns ms, or null) against an in-memory fake.

* Make useDesktopAppUpdater a ports-and-adapters unit test

Extract the check/install state machine from the React hook into a pure
createDesktopAppUpdater runtime that exposes getSnapshot/subscribe/
checkForUpdates/installUpdate. The hook now wires real production deps
into the runtime and bridges its snapshot via useSyncExternalStore;
React-driven concerns (pending-update interval, initial silent check)
stay in the hook.

Drops JSDOM, @testing-library/react, renderHook, the three vi.mock
calls and the vi.hoisted state shim from the test. The new test wires
a typed FakeDesktopAppUpdaterPort recording recordedChecks/recordedInstalls
and exposes deferNextCheck/failNextCheck/nextInstallResult so the
behaviour assertions read as plain English (status transitions through
checking, available, pending, up-to-date, error; race cancellation drops
older results; install errors get reported once).

* Make sidebar-collapsed-sections-store a ports-and-adapters unit test

* Make session-store-hooks a ports-and-adapters unit test

* Make panel-store a ports-and-adapters unit test

* Make workspace-tabs-store a ports-and-adapters unit test

* Make desktop-preview-url a ports-and-adapters unit test

* Make client-id a ports-and-adapters unit test

* Make browser-store a ports-and-adapters unit test

* Make local-file-attachment-store a ports-and-adapters unit test

* Make draft-store a ports-and-adapters unit test

* Make rich-clipboard a ports-and-adapters unit test

* Make desktop-daemon-transport a ports-and-adapters unit test

* Make image-attachment-picker.native a ports-and-adapters unit test

Extract the pure normalize logic into image-attachment-picker.native.pure.ts
and take the PNG exporter as a port. The .native.ts entry wires the real
expo-image-manipulator adapter; the test wires a fake exporter and asserts
on its recorded uris.

Drops vi.mock("expo-image-manipulator") and the inline ImageManipulator
stub from the test. Production callers of normalizePickedImageAssets are
unchanged.

* Make tool-call-icon a ports-and-adapters unit test

Split the pure icon-identity decision into tool-call-icon-name.ts and
keep the lucide/PaseoLogo component lookup in tool-call-icon.ts. The
resolver returns a ToolCallIcon string ("bot", "brain", "paseo", ...);
componentForToolCallIcon does the React component mapping; the existing
resolveToolCallIcon is the composition.

Drops vi.mock("lucide-react-native") from the test and the brittle
"expect(icon).toBe(iconMocks.Bot)" pattern — the unit project couldn't
evaluate lucide-react-native, which is why the mock existed in the
first place. The test now asserts on string identifiers and imports
only the pure module.

buildToolCallPresentation's resolveIcon port is unchanged; the single
caller (components/message.tsx) keeps passing resolveToolCallIcon and
gets the same component back.

* Make desktop-permissions a ports-and-adapters unit test

The test was the heaviest globalThis-juggling test in the app package:
vi.doMock("react-native") + vi.resetModules() per case to swap the
platform, ensureWindow/setNavigator/restoreGlobals to swap
globalThis.Notification, globalThis.navigator, and window.paseoDesktop.
That whole setup existed because the production module reached into
four ambient sources (Notification, navigator, getDesktopHost(),
isWeb/isNative) without a port.

Extract a DesktopPermissionEnvironment interface — { isWeb,
getDesktopHost, getNotification, getNavigator } — and rebuild the
module around a createDesktopPermissions(env) factory. The real
environment binds to the actual globals at module load and the
existing top-level exports (shouldShowDesktopPermissionSection,
getDesktopPermissionSnapshot, requestDesktopPermission) are thin
references onto it, so the only caller (use-desktop-permissions.ts) is
unchanged.

The test now constructs a fakeEnvironment per case and calls into
createDesktopPermissions directly — no JSDOM, no vi.doMock, no
vi.resetModules, no globalThis writes. The eight behaviors are
preserved.

* Make provider-icons a ports-and-adapters unit test

Split the pure provider→icon-identity decision into provider-icon-name.ts
and keep the lucide/SvgXml/catalog component lookup in provider-icons.ts.
resolveProviderIconName returns a ProviderIconName ({kind:"builtin"|"catalog"|"bot", id?}),
and getProviderIcon composes it with the catalog/builtin component maps.

Drops vi.mock("lucide-react-native") from the test — the previous
"expect(icon).toBe(iconMocks.Bot)" pattern only existed because the unit
project couldn't evaluate lucide-react-native. The new test asserts on
discriminated-union identifiers and imports only the pure module.

The 13 outside callers of getProviderIcon are unchanged.

* Make crypto polyfill a ports-and-adapters unit test

* Delete redundant JSDOM Index route test

packages/app/src/app/index.test.tsx mounted the Index component through
JSDOM + createRoot + @testing-library/react, mocked five modules
(expo-router, _layout, desktop-daemon, startup-splash-screen,
navigation-active-workspace-store), and asserted that <Redirect> got
rendered with the right href across six scenarios.

Each of those six scenarios is a one-to-one duplicate of a pure case
already covered in host-runtime-bootstrap.test.ts, which tests the same
two decision functions (resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection) directly. The component is pure
wiring: it reads four hooks, calls the two decision functions, and
renders the result. There is no logic in Index to verify that the pure
tests do not already cover.

Drop the JSDOM file; the wiring is covered by app E2E.

* Make review-draft-store a ports-and-adapters unit test

* Make new-workspace-empty a ports-and-adapters unit test

* Extract shared drag-reorder state machine for web sortable lists

* Extract pure subagents track presentation helpers

Move formatHeaderLabel and resolveRowLabel out of track.tsx into a
colocated track-presentation.ts. Six header-copy tests that previously
mounted React under JSDOM with eight vi.mocks now run as plain unit
tests against the pure helpers.

* Make terminal-file-drop a ports-and-adapters unit test

* Extract pure import-session-sheet view-model helpers

Move resolveProvidersToFetch, buildProviderLabelMap, aggregateSessionEntries,
sumFilteredAlreadyImportedCount, collectErroredProviderLabels, getSessionTitle,
getPromptPreview, and computeEmptyState out of import-session-sheet.tsx into a
colocated import-session-sheet.pure.ts. Add 28 pure unit tests that exercise
provider resolution, dedupe/sort, error label fallback, title fallback, and
the empty-state state machine directly — no JSDOM, no vi.mock.

The existing 859-line JSDOM import-session-sheet.test.tsx stays for now; next
ticks can replace its status-message and empty-state cases with the pure
coverage.

* Make subagents track a ports-and-adapters unit test

Move the row-presentation data builder from track.tsx into the colocated
pure track-presentation.ts and cover statusBucket, titleState, and label
in plain node tests. Delete the 235-line JSDOM track.test.tsx with its
eight vi.mocks and createRoot harness — the remaining behaviours (empty
returns null, useState toggle, onPress wiring) are React idioms covered
by the framework, not domain logic.

* Make isolated-bottom-sheet-modal a ports-and-adapters unit test

* Stop UI from leaking through subagents barrel

The subagents/index.ts barrel re-exported SubagentsTrack (the React
component) alongside pure logic like selectSubagentsForParent. Any pure
consumer of the barrel transitively pulled in lucide-react-native and
react-native-unistyles, forcing tests that only touch pure logic to
declare cosmetic vi.mock blocks for icons, theme, tooltip, and provider
icons just to get the import graph to load.

Drop SubagentsTrack from the barrel. The one external caller
(panels/agent-panel.tsx) deep-imports from @/subagents/track instead.
The workspace-subagents-integration test loses 56 lines of pretend-UI
mocks; only the AsyncStorage mock remains because workspace-layout-store
uses it directly through persist middleware.

* Collapse use-hover-safe-zone.pure.ts into hover-safe-zone-tracker.ts

The `.pure.ts` suffix mimics tooling-resolved variants (`.web.ts`,
`.native.ts`, `.test.ts`) without being one. The file exports a single
tracker; rename it after its role. Test sits next to its subject by name.

* Promote use-settings to a directory module

* Promote sidebar-collapsed-sections-store to a directory module

* Promote navigate-to-agent to a directory module

The `.pure.ts` filename suffix mimics tooling-resolved variants
(`.web.ts`, `.native.ts`, `.test.ts`) without being one. Move the pure
resolver and its wired wrapper into `utils/navigate-to-agent/`, where
the directory carries the domain and `resolve.ts` names the role. The
pure function is renamed `resolveNavigateToAgent` so it no longer
collides with the wrapper's exported `navigateToAgent`.

* Collapse use-client-activity.pure.ts into client-activity-tracker.ts

* Collapse open-project.pure.ts into open-project.ts

* Promote use-changes-preferences to a directory module

Replaces the .pure.ts / .test-utils.ts double-suffix with a
directory home that mirrors hooks/use-settings/:

  hooks/use-changes-preferences/
    index.ts          ← React hook; wires AsyncStorage
    storage.ts        ← pure load/save
    storage.test.ts   ← tests against storage
    fakes.ts          ← in-memory KeyValueStorage adapter

External callers continue importing @/hooks/use-changes-preferences
unchanged (resolves to index.ts).

* Promote draft-store to a directory module

* Collapse use-archive-agent.pure.ts into use-archive-agent.ts

* Rename import-session-sheet.pure.ts to import-session-sheet-view-model.ts

* Rename image-attachment-picker.native.pure.ts to picked-image-normalizer.ts

* Inline use-agent-initialization.pure.ts into use-agent-initialization.ts

* Rename use-sidebar-workspaces-list.pure.ts to sidebar-workspaces-view-model.ts

* Rename review/store.pure.ts to review/state.ts

* Promote browser-store to a directory module

* Promote navigation-active-workspace-store to a directory module

* Promote panel-store to a directory module

* Promote session-store-hooks to a directory module

* Promote workspace-tabs-store to a directory module

* Rename use-archive-subagent.pure.ts to archive-subagent.ts

* Rename sidebar-workspace-archive-redirect.pure.ts to workspace-archive-redirect.ts

* Rename workspace-navigation.pure.ts to prepare-workspace-tab.ts
2026-05-28 12:34:31 +08:00
paseo-ai[bot]
5cede0a7bb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-27 18:01:44 +00:00
Mohamed Boudra
53c14d9855 Extract client SDK package (#1052)
* Extract client SDK package

* Polish SDK client identity defaults

* Build client before dependent CI jobs

* Restore daemon client server export

* Extract protocol and client SDK packages

* Fix provider override schema validation

* Fix app test daemon client imports

* Simplify workspace build targets

* Fix CLI test server build bootstrap

* Run SDK package tests in CI

* Fix rebase package split drift

* Restore lockfile registry metadata

* Update SDK config test for prompt default

* Move terminal stream router test to client package

* Fix rebase drift for protocol imports

* Fix SDK agent capability fixture

* Restore legacy server client exports

* Fix server export compatibility test

* Advertise custom mode icon client capability

* Remove server daemon-client exports

* Format rebased mode control import

* Fix rebase drift for protocol imports

Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split
shared/ paths that this branch moves into @getpaseo/protocol. Redirect
those imports to the protocol package so typecheck stays green after the
rebase.
2026-05-28 01:58:18 +08:00
Mohamed Boudra
0ea41378a4 Fix provider binary diagnostics for command overrides (#1191)
* fix: align provider binary diagnostics

* Fix provider launch tests on Windows

* Separate provider launch availability checks
2026-05-28 00:54:43 +08:00
Mohamed Boudra
00759e7994 Fix provider-selection test for synthetic default row
The selector rework now synthesizes a "Default" row for ready enabled
providers without explicit models, so they share the same `kind: "models"`
shape as providers with real models. Updated the stale assertion that
still expected the old `providerDefault` kind.
2026-05-27 23:14:33 +07:00
Mohamed Boudra
e3eb333ddc Render opencode Edit tool calls as diffs
opencode's Edit tool sends camelCase input keys (filePath, oldString,
newString) and a plain-string success ack on output. The shared edit
schemas expect snake_case input and an object output, so calls fell
through to the generic "unknown" renderer (raw JSON Input/Output).

Normalize both at the opencode boundary so the quirk does not leak into
the cross-provider schema.
2026-05-27 22:43:08 +07:00
Mohamed Boudra
a025f17a73 Fix workspace git service tests for facts optimization
Tests stubbed the old dep set and asserted on call signatures
that no longer match after the getCheckoutSnapshotFacts work was
threaded through refresh/observation/getPullRequestStatus.
2026-05-27 22:29:59 +07:00
Mohamed Boudra
a4cb7431d8 Rework provider selector and settings UX
Surface all enabled providers in the model selector with inline loading
and error states instead of silently hiding non-ready ones. Tap any row
to drill in; loading and error states show in the drill-down body, with
a Retry button on error. Providers with no models render a synthetic
"Default" row so every provider's drill-down has symmetric structure.

Redesign the per-provider settings sheet: search lives in the header,
discovered and custom models share one scrolling list, and the footer
hosts action buttons. Diagnostic and "Add model" each open as their own
sub-sheet, so neither expands inline or shifts the surrounding layout.

Snapshot refresh now keeps cached data visible: setQueryData replaces
the cache atomically and sibling-scope caches are invalidated (not
removed), so refreshing a provider no longer blanks its row. Search
inputs across the app are uncontrolled — onChangeText drives parent
state, and resetKey remounts the field when an explicit reset is needed
— to avoid RN's controlled-input flicker.

Adds a dev-only mock-slow provider whose discovery never resolves, so
the 30s snapshot timeout produces a real error and lets loading and
timeout-error UI be exercised end to end.
2026-05-27 22:00:34 +07:00
Mohamed Boudra
5696cdb455 refactor(server): integrate session MCP command stack (#893)
* Extract permission response command

* Extract create agent command

* Extract agent lifecycle commands

* refactor(server): extract worktree archive command

* refactor(server): share worktree create list commands

* test(server): update close items lifecycle fakes

* Fix MCP command stack CI regressions

* Restore MCP update_agent no-op success semantics

After the rebase, MCP `update_agent` was returning `success: false` for empty/no-op calls (no name, no labels, no settings). Origin/main returned `success: true` unconditionally. The audit flagged the change as out of scope for this stack — restore the old behavior at the MCP boundary.

Session WS path keeps its accepted/rejected semantics (it surfaces an error when nothing was provided so the client can prompt the user).

* Fix agent metadata delegation and mode persistence

* Update MCP update_agent test for metadata delegation

* Fix stored agent metadata timestamps
2026-05-27 22:43:03 +08:00
Mohamed Boudra
5a56835db3 Add startup diagnostics reports 2026-05-27 20:59:06 +07:00
Mohamed Boudra
698d549983 Title-case agent landing page meta titles 2026-05-27 20:52:33 +07:00
Mohamed Boudra
fa1b3e88f0 Reuse git facts for workspace observation 2026-05-27 20:33:21 +07:00
Mohamed Boudra
5d8dc800fc Reduce startup git work for workspace snapshots 2026-05-27 20:18:49 +07:00
Mohamed Boudra
dbfd42da46 Remove Reddit icon from site header 2026-05-27 19:10:55 +07:00
Mohamed Boudra
2894917a1c Add Reddit link to website and README 2026-05-27 19:01:17 +07:00
Mohamed Boudra
9c2d47ab34 Deploy website for docs changes 2026-05-27 16:17:10 +07:00
Mohamed Boudra
d787aefa4c Fix OpenCode MCP injection on wildcard binds
Wildcard listen addresses are bind targets, not client endpoints. Inject loopback for agent MCP URLs and surface OpenCode MCP add failures returned in data payloads.
2026-05-27 16:01:06 +07:00
Mohamed Boudra
8307a0ca6f docs: add Codex custom OpenAI-compatible endpoint example
Promote the brief Codex env-var note into a dedicated section next to
the Z.AI and Qwen examples, showing exactly which env vars Paseo wires
into Codex's model_providers thread config and how base_url, wire_api,
env_key, and requires_openai_auth are derived.
2026-05-27 15:57:34 +07:00
Mohamed Boudra
7aa49b2905 Add SEO landing pages 2026-05-27 13:40:49 +07:00
Mohamed Boudra
d594bce153 Rank slash command autocomplete matches 2026-05-27 13:40:49 +07:00
Mohamed Boudra
a630986d06 Align user message footer controls 2026-05-27 13:40:49 +07:00
paseo-ai[bot]
724a499413 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-27 04:59:03 +00:00
Li Mu Zhi
8aa1530be1 fix(app): native iOS text selection in assistant messages via UITextView
Fixes #238
Fixes #648
Refs #21
2026-05-27 04:55:46 +00:00
Mohamed Boudra
1908ab8765 Move context ring to footer right edge on compact 2026-05-27 11:13:23 +07:00
Mohamed Boudra
5ad6cff039 Drop color treatment from agent mode controls 2026-05-27 10:56:31 +07:00
Mohamed Boudra
8a463c55c8 Update daemon hello capability test 2026-05-27 09:59:58 +07:00
Mohamed Boudra
aecb300073 Stabilize OpenCode provider unit tests 2026-05-27 09:51:40 +07:00
Mohamed Boudra
4911b627f2 Treat agent mode icons as open strings
Old clients pinned AgentModeIcon to a closed enum and rendered
undefined for unknown values, crashing the route in production
Hermes with no stack trace. Loosen the type to a plain string,
let the client skip rendering when an icon is unregistered, and
add the customModeIcons capability so the daemon downgrades
non-legacy icons to ShieldCheck for clients that pre-date the
open-string contract.
2026-05-27 09:30:50 +07:00
Mohamed Boudra
a5b82d2a3b Relax MCP tool output validation 2026-05-26 23:22:09 +07:00
Mohamed Boudra
09bf981f13 Reshape stream head/tail spacing 2026-05-26 23:01:42 +07:00
Mohamed Boudra
3cf92ad6c5 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-26 22:35:38 +07:00
Mohamed Boudra
153fa42a95 Tighten markdown list spacing 2026-05-26 22:27:18 +07:00
Mohamed Boudra
6d205f8853 Add OpenCode auto accept feature 2026-05-26 21:51:04 +07:00
paseo-ai[bot]
5468089ac0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-26 13:54:15 +00:00
Mohamed Boudra
0ab41fbd9a chore(release): cut 0.1.83 2026-05-26 20:50:25 +07:00
Mohamed Boudra
ed6caa11c0 Update changelog for 0.1.83 2026-05-26 20:49:15 +07:00
Mohamed Boudra
74c8942a28 Confirm create-agent start before returning
Wait for the initial background turn to actually start before create returns a snapshot, without waiting for completion.

Freshly created agents now surface start failures as create failures, and both the WS/client and MCP surfaces reflect the shared create-agent lifecycle helper.
2026-05-26 20:38:27 +07:00
Mohamed Boudra
3eb1ba7d73 Narrow MCP schedule cadence handling to blank values 2026-05-26 20:15:12 +07:00
Mohamed Boudra
1d2c8b1648 Fix MCP schedule cadence placeholder handling
Treat undefined, blank, whitespace-only, and /__omit__ cadence values as absent at the MCP boundary for schedule create and update.

That keeps placeholder inputs from tripping cadence validation while preserving the real invalid cases for conflicting or missing cadence values.
2026-05-26 20:15:11 +07:00
Mohamed Boudra
88914ccba6 Fix draft mode chip for non-thinking models
Keep the desktop controls row alive when desktop extras exist, so draft mode chips still render even when a model has no thinking options.\n\nRestore the replacement draft e2e assertion for the mode chip now that it renders again.
2026-05-26 20:15:11 +07:00
paseo-ai[bot]
f79101a1f0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-26 10:13:47 +00:00
Mohamed Boudra
b492c70825 chore(release): cut 0.1.82 2026-05-26 17:09:51 +07:00
Mohamed Boudra
3ace6c5602 Defer slash-clear draft mode assertion to follow-up
After the composer refactor the replacement draft's mode picker
stopped rendering because the draft's provider snapshot does not
surface modes for the running provider. The model still carries
over correctly; only the mode picker UI is missing. Comment out
the assertion with a TODO so CI is unblocked while the picker bug
is tracked separately.
2026-05-26 16:47:27 +07:00
Mohamed Boudra
be7979fb34 Update slash clear test to expect formatted mode label
The composer refactor changed the agent-mode button's accessibility
label from the raw mode id ("load-test") to the formatted label
("Load test"). The user-facing change is intentional. Bring the test
in line with the new label.
2026-05-26 16:25:24 +07:00
Mohamed Boudra
58e7fd0e3e Close rewind menu when the action errors
handleSelect only ran setIsOpen(false) on the success path. When the
rewind action failed, the catch block swallowed the error but the menu
stayed open, blocking subsequent interactions with the page.

Close in finally so success and error both dismiss the menu.
2026-05-26 15:37:17 +07:00
Mohamed Boudra
81407c5ffc Derive tab testIDs from the tab target
The tab testID was reading from tabId, which intentionally stays as the
draft id after a draft -> agent handoff so the panel React key remains
stable and the component does not remount. The data-testid was riding
along on that identity and stayed workspace-tab-draft_xxx forever, even
though the tab semantically became an agent. Tests that look for
workspace-tab-agent_* timed out.

Read testIDs from tab.target via buildDeterministicWorkspaceTabId so the
testID reflects what the tab represents. React key is unchanged.
2026-05-26 15:37:08 +07:00
Mohamed Boudra
9c5f6007be Update changelog for 0.1.82 2026-05-26 15:04:41 +07:00
Mohamed Boudra
fab721205d Fix autocomplete popover render warning 2026-05-26 14:51:43 +07:00
Mohamed Boudra
e74c7f9554 Show OpenCode agent colors in modes 2026-05-26 14:36:45 +07:00
Mohamed Boudra
4231dbbba4 Organize composer into its own module 2026-05-26 14:32:45 +07:00
Mohamed Boudra
7e792e899a Antialias web app fonts 2026-05-26 12:56:38 +07:00
Mohamed Boudra
1d38aacf5c Fix OpenCode replacement prompts after interrupt 2026-05-26 11:54:36 +07:00
Mohamed Boudra
a3071b46a4 Fix optimistic user message reconciliation 2026-05-26 11:37:13 +07:00
Mohamed Boudra
483790d8c9 Replay completed tool results from provider history 2026-05-26 11:36:27 +07:00
Mohamed Boudra
bde0efc9b0 Stop rereading provider snapshots while loading 2026-05-26 11:35:20 +07:00
Mohamed Boudra
6f16900b8a Hide paseo-system prompts from the agent timeline
System-injected prompts (finish notifications, schedule fires) are wrapped
in a <paseo-system> envelope and dispatched as regular user messages to
the provider. The app was showing them as user-message turns. Detect the
envelope at the manager boundary and skip them during live runs and
history replay so only real user input renders.
2026-05-26 11:28:22 +07:00
Mohamed Boudra
c31ab074e9 Fix autocomplete popover anchoring
Position portal content relative to its render host so the composer anchor stays correct when surrounding layout shifts. This preserves the Portal surface needed for Android scrolling and touch behavior.
2026-05-26 11:17:03 +07:00
Mohamed Boudra
bf69ebeddf Stop persisting agent prompt as the backend title
config.title is now strictly the caller's creation intent and is never
mutated. The prompt-derived placeholder lives only in record.title at
creation, so the metadata generator's upfront gate (hasExplicitTitle) is
the only one and writes unconditionally when it runs. Drops the two
projection fallbacks to record.config.title that were the second
hidden path for the prompt to surface as a title.
2026-05-26 10:42:36 +07:00
Mohamed Boudra
22e014aaff Fix mobile autocomplete sidebar layering 2026-05-26 10:02:27 +07:00
Mohamed Boudra
91b05b4111 Fix autocomplete popover flicker 2026-05-26 09:35:28 +07:00
Mohamed Boudra
6e56ee9e32 Fix terminal color query replies
Answer OSC color queries from the daemon and suppress browser-side replies so response bytes do not race back into the shell prompt as user input.
2026-05-26 09:32:15 +07:00
Mohamed Boudra
48d4e3e408 Reduce draft tab descriptor updates 2026-05-25 21:37:04 +07:00
Mohamed Boudra
30a8bffc24 Show optimistic chat for new workspaces 2026-05-25 21:26:31 +07:00
Mohamed Boudra
d271597ee8 Clean up read tool activity content 2026-05-25 21:08:11 +07:00
Mohamed Boudra
893dd6d1fe Fix write tool activity mapping 2026-05-25 20:59:44 +07:00
Mohamed Boudra
53faba64ca Fix draft agent handoff continuity
Move optimistic user message reconciliation into the stream reducers and keep draft-created agents in a handoff lifecycle until authoritative history lands.
2026-05-25 20:53:49 +07:00
Mohamed Boudra
e5658654c0 Open rewind in an anchored dropdown menu
Replace the full-screen rewind sheet with a dropdown anchored to the
trigger, with a muted warning header and inline pending state on the
chosen action. Also realign the user message action row so timestamp,
rewind, and copy share a single 24px baseline — the copy button was
inheriting assistant-footer paddings that pushed the row taller than
the icons.
2026-05-25 20:18:57 +07:00
Mohamed Boudra
f0730d5eac Polish compact workspace header actions 2026-05-25 19:50:31 +07:00
Mohamed Boudra
e312e0d6f3 Focus mobile terminal only on tap 2026-05-25 19:27:45 +07:00
Mohamed Boudra
9806a893ed Improve mobile terminal keyboard layout 2026-05-25 19:26:38 +07:00
Mohamed Boudra
5dd2afaac9 Hide terminal keyboard when opening sidebars 2026-05-25 19:26:38 +07:00
Mohamed Boudra
2252046f56 Fix mobile terminal focus and background 2026-05-25 19:26:25 +07:00
Mohamed Boudra
423956c6a0 Preserve draft composer permission mode (#1175) 2026-05-25 19:19:44 +08:00
Mohamed Boudra
19289286a6 Accept dropped files in the terminal (#1173) 2026-05-25 19:16:39 +08:00
Mohamed Boudra
e40ad0c00e Link terminal file paths to workspace previews (#1174) 2026-05-25 19:12:43 +08:00
Mohamed Boudra
1e68283565 Allow PR merge when GitHub reports it ready (#1172) 2026-05-25 10:05:35 +00:00
ElliotWu
8a2e5c786e fix(app): swallow URIError in assistant file link parser on bare '%'
decodeURIComponent throws on '%' not followed by two hex digits. The two
calls inside parseAssistantFileLink and normalizeFileUrlPath had no
try/catch, so any timeline message containing strings like '100% packet
loss' (ping output), '%PATH%' or '0% off' could escape into the React
tree and unmount the agent route, leaving the renderer blank.

Wrap both calls in a local safeDecodeURIComponent that returns the input
unchanged when decoding fails, mirroring the helper already used in
host-routes.ts.

Closes #1148
2026-05-25 10:05:00 +00:00
Mohamed Boudra
178708a44e Show OpenCode tool activity consistently (#1171)
* fix(server): map OpenCode tool details

* test(server): update OpenCode history client stub
2026-05-25 18:04:33 +08:00
Mohamed Boudra
05eec04b10 Format settings latency readouts (#1170) 2026-05-25 17:47:31 +08:00
ayhan malkoc
b7ea5b4c9d Show workspace scripts on mobile header (#1093) 2026-05-25 17:45:48 +08:00
Alcimério Rangel
655d05add8 Add Devin CLI to the ACP provider catalog 2026-05-25 17:38:56 +08:00
Mohamed Boudra
f137705f1e Fix provider models per workspace (#1167)
* Scope provider snapshots by workspace

* Fix provider snapshot cwd test on Windows
2026-05-25 17:12:15 +08:00
Mohamed Boudra
6b35c0a640 Add persistent OpenCode permission actions (#1168) 2026-05-25 17:11:53 +08:00
Mohamed Boudra
a91dfb63c6 Treat OpenCode abort as cancellation, not error (#1169)
OpenCode emits session.error with MessageAbortedError when the user
interrupts a running turn. Paseo was mapping every session.error to
turn_failed, which set the agent lifecycle to error and appended a
raw JSON system error message to the timeline.

Now MessageAbortedError translates to turn_canceled (same as an
explicit cancel), so the agent returns to idle with no error state.
Real errors still produce turn_failed.
2026-05-25 17:11:44 +08:00
Mohamed Boudra
132e572d6e Rewind chat or files from any user message (#1154)
* Add Pi extension-launch plumbing for in-process tree primitives

* Add Rewind controls for agent sessions

* Preserve SDK checkpoint env var so Claude file rewind works

The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.

Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.

* Update OpenCode history replay tests for rewind state

* Show Rewind actions in an adaptive sheet

* Fix rewind semantics across providers

* Refactor Pi IDs through extension capture

* Restore user message to composer after rewind

* Fix Claude rewind tool result mapping

* Fix Claude rewind session switch roundtrip

* Fix rewind and optimistic message regressions

* Tighten rewind test discipline

* Fix OpenCode optimistic message reconciliation

* Reconcile optimistic user messages by marker

* Reorder user message actions

* Unify provider user message contract

* Add Claude rewind flow Playwright contract

* Add Codex rewind flow Playwright contract

* Add OpenCode rewind flow Playwright contract

* Add Pi rewind flow Playwright contract

* Remove real-provider rewind Playwright contracts

* Gate real-provider rewind specs from CI Playwright
2026-05-25 17:11:34 +08:00
Mohamed Boudra
e966f70322 Merge remote-tracking branch 'origin/main' 2026-05-25 12:19:49 +07:00
Mohamed Boudra
62780448dc refactor(app): organize agent stream rendering 2026-05-25 12:15:20 +07:00
Mohamed Boudra
5755ca77f8 Show cumulative session cost (#1163) 2026-05-25 12:13:37 +08:00
Mohamed Boudra
ba724956df feat(website): serve /llms.txt and raw markdown for docs
Adds an llms.txt at the site root with a static product preamble so
LLMs can answer "what is Paseo" without following any links, plus a
curated index of docs, alternatives, and the 38 agent pages. Doc pages
now expose Copy/View as markdown buttons and a raw .md version at
<path>.md following the llms.txt convention.

Also skips the canonical-host redirect for localhost so the dev server
stops punting every request to production.
2026-05-24 21:38:33 +07:00
paseo-ai[bot]
7fbbb44ad6 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-24 06:16:43 +00:00
Mohamed Boudra
4c97ff8fa0 chore(release): cut 0.1.81 2026-05-24 13:13:01 +07:00
Mohamed Boudra
c0d2f20056 Update changelog for 0.1.81 2026-05-24 13:11:48 +07:00
Mohamed Boudra
2a3cfc684b Merge branch 'main' of github.com:getpaseo/paseo 2026-05-24 12:59:53 +07:00
Mohamed Boudra
68c893f643 fix(server): inject OpenCode MCP servers once 2026-05-24 12:58:30 +07:00
Mohamed Boudra
d43d30eeeb fix(server): log each provider fallback during metadata generation 2026-05-24 12:54:08 +07:00
Mohamed Boudra
79dcbdc1c1 Fix false unpushed commit warnings for worktree archive (#1158)
* fix(server): avoid false unpushed worktree commits

* fix(server): preserve local-only worktree push counts
2026-05-24 12:41:36 +08:00
paseo-ai[bot]
94bccf19ba fix: update lockfile signatures and Nix hash [skip ci] 2026-05-24 04:13:18 +00:00
Mohamed Boudra
83f205bd3d Preserve assistant message formatting on copy 2026-05-24 04:10:09 +00:00
Mohamed Boudra
846c9b9da3 Make mobile terminals load faster (#1147)
* Speed up terminal restores on mobile

* Preserve terminal output bytes in the app
2026-05-23 18:41:10 +08:00
paseo-ai[bot]
84f1dfc8cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-23 10:25:37 +00:00
Mohamed Boudra
33892f0698 fix(app): stop terminal flicker on resize 2026-05-23 17:22:24 +07:00
Mohamed Boudra
0962529d48 Make the web app installable (#1144)
* feat(app): make web app installable

* fix: restore CI for import and shortcut tests
2026-05-23 12:17:51 +08:00
Mohamed Boudra
008e4e846f fix(app): make slash command popover interactive on Android
The popover was positioned `bottom: 100%` of its parent inside the composer
and worked on iOS/web; Android's hit-test by parent bounds drops touches on
children that overflow their parent, so taps and scrolls went through to the
chat behind it. Move the popover into a `<Portal>` at the app root and apply
the same Reanimated keyboard SharedValue as the composer so it tracks the
keyboard in lockstep. New `docs/floating-panels.md` captures the gotchas
(Android hit-test, Portal lifecycle/transforms, status-bar offset, the
two-measurement flash) and the canonical files.
2026-05-22 20:44:37 +07:00
Mohamed Boudra
789a559b31 fix(app): make sheet header search text readable in dark mode
The model picker search input rendered text in the light-theme
foreground color on a dark surface, leaving typed characters invisible.

@gorhom/bottom-sheet mounts header subtrees before the sheet is visible
and keeps them mounted across theme changes, so any color read from
StyleSheet.create((theme) => ...) at the caller goes stale. Move text
color and placeholder color into AdaptiveTextInput itself via
withUnistyles, with the leaf's color appended last so callers cannot
re-introduce a stale read. Drop the now-redundant placeholderTextColor
props at the header search call sites.

Also ban useUnistyles() in docs/unistyles.md. The hook subscribes to
every runtime change and forces a re-render each cycle; new code uses
withUnistyles or StyleSheet.create instead.
2026-05-22 18:23:11 +07:00
Mohamed Boudra
f5f1ae7fa9 fix(app): show one /exit row with /quit and /q as aliases 2026-05-22 18:22:19 +07:00
Mohamed Boudra
8e0ebfcaaa fix(server): hide empty sessions from the import list 2026-05-22 18:12:15 +07:00
Mohamed Boudra
b151dfcfd5 fix(app): show segmented control track under all segments 2026-05-22 18:05:35 +07:00
Mohamed Boudra
de7bf2fb01 fix(app): restore Alt+letter shortcuts on macOS
The key matcher only consulted event.code when a binding explicitly
opted in via codeFallback. macOS rewrites event.key whenever Option
is held (Option+T -> "†", Option+[ -> "“", Option+Shift+W -> "„",
etc.), so the comparison against combo.key always failed and every
Alt-bound letter / bracket shortcut silently stopped firing on Mac:
Cmd+Alt+T (cycle theme), Alt+Shift+[/], Alt+[/], Alt+Shift+W.

Fall back to event.code when combo.alt is set. Keep the existing
key-first behaviour for non-Alt bindings so Dvorak users still get
logical-character matching (Cmd+V on physical Period keeps pasting
instead of triggering Cmd+. for sidebar toggle).
2026-05-22 17:55:48 +07:00
Mohamed Boudra
5f11f602fb feat(app): add community links and sidebar home button 2026-05-22 17:40:42 +07:00
Mohamed Boudra
369c5a4498 fix(app): show shortcut chord badges in light mode
The Shortcut component computed its colors with a hex-only opacity
helper that received unistyles' CSS-var theme strings on web (e.g.
"var(--colors-foreground)"), fell through both regex branches, and
returned the rgba(255,255,255,...) fallback. Badges and chord text
rendered as near-white on white, so the keyboard shortcuts settings
page looked blank. Use the existing surface2 and foregroundMuted
tokens directly, which work in both themes without per-platform
color math.
2026-05-22 17:28:58 +07:00
Mohamed Boudra
c46ff2e045 feat(app): native terminal on WebView with xterm.js
Replace the Expo DOM-backed terminal on iOS and Android with a managed
WebView running xterm.js. Web and desktop continue to use the existing
DOM implementation.

Mobile mounted tabs switch from display:none to opacity:0 for the hidden
slot on purpose: terminals stay mounted under the LRU tab cache, and
keeping the WebView in the layer tree avoids cold-starting it every time
the user returns to a terminal tab.

EAS native builds rebuild the WebView bundle post-install so the
generated HTML stays in sync with the entry source.

This does not fix the WS 1006 / NSPOSIXErrorDomain Code=54 host-disconnect
symptom that prompted the investigation. After restoring the baseline,
that symptom could not be replicated; do not read this commit as its
root cause or fix.
2026-05-22 15:33:58 +07:00
Yurui Zhou
af10e64f82 feat(pi): bridge extension UI dialogs (#1134) 2026-05-22 00:03:42 +08:00
paseo-ai[bot]
db44a3e0e1 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 13:06:14 +00:00
Mohamed Boudra
25c4cee01e chore(release): cut 0.1.80 2026-05-21 20:02:05 +07:00
Mohamed Boudra
3ec4e2c536 docs(changelog): draft 0.1.80 entry 2026-05-21 20:00:36 +07:00
Mohamed Boudra
7dd9cbc506 fix(app): keep inline style marker web-only 2026-05-21 19:58:31 +07:00
paseo-ai[bot]
6dc22483c5 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 10:11:01 +00:00
Mohamed Boudra
a9f5b8ea4d chore(release): cut 0.1.79 2026-05-21 17:03:25 +07:00
Mohamed Boudra
1ce30bac05 docs(changelog): draft 0.1.79 entry 2026-05-21 16:51:15 +07:00
Mohamed Boudra
754f5a1f0b Tweak import session tile copy 2026-05-21 16:27:49 +07:00
Mohamed Boudra
698390bf1c feat(app): copy resume command for pi agents 2026-05-21 16:23:23 +07:00
Mohamed Boudra
63b24ec261 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 16:19:04 +07:00
Mohamed Boudra
ad060e2f86 feat: import existing CLI sessions from the home screen
Home screen now leads with action tiles (Add a project, Import session,
Setup providers, Pair device) instead of a single button, giving new
users a clear path in even before they've added a project.

Import session is the onboarding hook: the sheet now works without a
workspace context and lists recent sessions from every provider's native
history (Claude, Codex, OpenCode, Pi, ACP). Picking one imports the
agent and lands the user on its workspace.

To make that work end-to-end, the daemon upserts the imported agent's
cwd as a workspace so the sidebar surfaces it even when the user never
explicitly opened the folder.

Also adds a Home entry to cmd+K and a refresh button to the import
sheet header for explicit re-fetch.
2026-05-21 16:15:28 +07:00
paseo-ai[bot]
1e595ad9cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 08:21:38 +00:00
Mohamed Boudra
9fd93f8308 Merge remote-tracking branch 'origin/main' into release-beta-0-1-79-beta-3 2026-05-21 15:17:35 +07:00
paseo-ai[bot]
fac81f568a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 05:51:50 +00:00
Mohamed Boudra
57de8c3807 chore(release): cut 0.1.79-beta.3 2026-05-21 12:50:43 +07:00
Mohamed Boudra
0310fd3c3c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 12:48:30 +07:00
Mohamed Boudra
a837d3e5d1 fix(app): keep React aligned with native renderer 2026-05-21 12:44:19 +07:00
paseo-ai[bot]
b5a78b7261 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 04:26:52 +00:00
Mohamed Boudra
e630e205db chore(release): cut 0.1.79-beta.2 2026-05-21 11:23:34 +07:00
Mohamed Boudra
8865f41d58 fix(app): unblock android APK build by aligning native deps
The 0.1.79-beta.1 APK build failed on EAS because react-native-unistyles
3.2.4 (bumped in #1103) references JHybridObject::CxxPart, which only
exists in react-native-nitro-modules 0.35.x. We were still on 0.33.8 so
the Android NDK compile died with "no member named CxxPart". Web and
desktop builds didn't catch it because the unistyles C++ layer is
Android-only.

Bump nitro to 0.35.5 to match unistyles' expected API. While re-resolving
the tree, react-native 0.81.6 also tightens its react peer to ^19.1.4,
so bump react/react-dom alongside so npm install stays clean.

Verified with a local production-apk gradle build using EAS's exact
gradleCommand — assembleRelease completes and the APK lands.
2026-05-21 11:19:52 +07:00
Mohamed Boudra
b94527fe1f fix(app): show empty state in import sheet when no sessions match 2026-05-21 11:19:52 +07:00
Mohamed Boudra
dee8f485de Recover stale daemon connections with liveness probes (#1124)
Use top-level WebSocket ping/pong for client liveness so session RPC timeouts stay scoped to the operation that timed out. Back off inactive connection probes while a host already has a healthy active connection.
2026-05-21 11:43:22 +08:00
Mohamed Boudra
28415b3542 docs(release): make betas silent, add release-beta/stable skills 2026-05-21 10:20:21 +07:00
Mohamed Boudra
3dd305082e docs(alternatives): add hero mockup to alternative comparison pages 2026-05-21 10:12:04 +07:00
Mohamed Boudra
cae3deb6e7 fix(website): SEO canonicals, host redirects, sitemap, app noindex
Addresses the high-volume issues from the Ahrefs audit:
- Worker entrypoint 301s every request not on https://paseo.sh
  (www subdomain and http variants), killing the duplicate-page
  errors and the HTTP→HTTPS internal-link notices.
- pageMeta() emits <link rel="canonical"> and og:url per route;
  every head() call now passes its canonical path.
- /blog stops rewriting itself to /blog?drafts=false; the search
  param only appears when explicitly true.
- Sitemap includes /blog and each post.
- Trim homepage title under SERP truncation, rewrite /agents meta
  description under 160 chars, pad short descriptions on
  /privacy, /download, /cloud, /changelog, /docs.
- app.paseo.sh gets <meta robots=noindex,nofollow> and a
  Disallow-all robots.txt so the SPA shell stops polluting the
  crawl with H1-missing / no-OG / low-word-count warnings.
2026-05-21 10:07:41 +07:00
Mohamed Boudra
ea36f0879f docs(website): rewrite agent pages for SEO and desktop coverage
Every agent landing page now mentions both mobile and desktop, leads with
"open source", and uses a search-friendly hero title. Renames Pi to "Pi Agent"
and disambiguates other common-word names in titles.
2026-05-20 22:31:28 +07:00
Mohamed Boudra
3bca1a72e8 Create agents in worktrees with auto-archive (#1120)
* Add daemon worktree auto-archive spawning

* Refactor create agent lifecycle dispatch

* Stabilize Claude autonomous turn test
2026-05-20 22:41:15 +08:00
paseo-ai[bot]
68d88f0928 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 14:39:26 +00:00
Mohamed Boudra
8a69d66f8e chore(release): cut 0.1.79-beta.1 2026-05-20 21:35:34 +07:00
Mohamed Boudra
c7872d968b docs(changelog): draft 0.1.79-beta.1 entry 2026-05-20 21:34:01 +07:00
Mohamed Boudra
8c4f5940d6 fix(server): launch Pi through the Windows-aware spawn helper (#1121) 2026-05-20 22:31:23 +08:00
paseo-ai[bot]
9b2b511abe fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 12:17:32 +00:00
Matteo Pietro Dazzi
e58ea31d6d chore: upgrade vitest (#1091)
* chore: upgrade vitest

* chore: sync lock
2026-05-20 12:14:13 +00:00
吴天一
c88f6fb2c2 docs: update built-in provider references (#1105) 2026-05-20 09:45:38 +00:00
a3a8527a1c Fix Dvorak paste shortcut handling
Fixes #1083
2026-05-20 17:34:49 +08:00
Mohamed Boudra
f1b3e25344 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-20 16:24:58 +07:00
paseo-ai[bot]
18a1bdcf72 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 08:57:53 +00:00
Mohamed Boudra
f933bf6b3a fix: stop leaking CSS rules from dynamic UI styles (#1084) (#1103)
* fix: stop leaking CSS rules from dynamic position styles

* Fix floating surfaces on web

* Stop diff controls growing Unistyles rules

* Use inline style escape hatch in diff pane

* Centralize dynamic style escape hatches

* Restore startup workspace navigation

* Upgrade Unistyles for web cleanup fix

* Update review style test for inline styles

* Use redirect for startup workspace restore

* Update startup redirect test
2026-05-20 16:55:00 +08:00
Mohamed Boudra
905a0985f9 Forward create agent env to provider launches (#1112) 2026-05-20 15:23:05 +08:00
Zexin Yuan
ca913728d9 Add publicUseTls option to NixOS module (#1106) 2026-05-20 07:20:13 +00:00
Mohamed Boudra
0d495a9625 Fix startup workspace restore (#1111) 2026-05-20 07:00:29 +00:00
Chris Banes
3786cf3569 Wait for Cursor ACP slash commands before listing. (#1099)
cursor-agent publishes commands asynchronously via available_commands_update, so listCommands() returned an empty cache when called right after session/new. Enable waitForInitialCommands only on CursorACPAgentClient with a 10s timeout; other ACP providers keep the immediate-return default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 13:47:10 +08:00
Mohamed Boudra
8a6bdb2d01 Run daemon dev diagnostics on workers 2026-05-20 09:36:09 +07:00
paseo-ai[bot]
555d10f046 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-19 16:02:09 +00:00
Mohamed Boudra
a1a5119bc5 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-19 22:57:37 +07:00
Mohamed Boudra
4cf985c254 Run Pi agents through the installed Pi CLI (#1097)
* Run Pi through its RPC interface

Replace the embedded Pi runtime packages with a process-backed runtime that starts pi --mode rpc, keeps the runtime port thin, and leaves provider behavior in the Paseo session and mapper modules.

Add focused adapter and mapper tests plus real Pi E2E coverage for the installed binary path.

* Keep Pi provider code in one module

* Add Pi session imports

* Enable Paseo MCP tools in Pi agents

Route injected MCP servers through Pi's MCP adapter and make structured MCP results visible to model-only clients.

Normalize Pi MCP proxy calls so Paseo timelines show the underlying tool names.

* Fix Pi import symlink tests on Windows

Create directory links explicitly in symlink cwd fixtures so Windows server tests exercise the intended path-equivalence behavior.

* Use native realpath for import cwd matching

Resolve import cwd matchers with native realpath so Windows symlink and junction sessions compare against the same path shape used by persisted provider data.

* Fallback realpath for Windows import matching

Use plain realpath when native realpath cannot resolve a cwd so symlink-equivalent persisted sessions still match on Windows runners.

* Add daemon system prompt support for Pi

* Fix Windows realpath import matching

* Remove Pi RPC migration plan doc
2026-05-19 23:56:07 +08:00
Mohamed Boudra
0e1c590a3b Reduce workspace git refresh polling (#1102)
* Reduce workspace git refresh polling

* Keep workspace git snapshot reads passive

* Update workspace git refresh tests
2026-05-19 23:54:52 +08:00
Mohamed Boudra
b7a8567092 Restore the previous workspace on app start (#1101)
* Restore previous workspace on app start

* Guard workspace startup hydration

* Extract last workspace selection store
2026-05-19 23:53:57 +08:00
Mohamed Boudra
ad9b149bf7 Keep workspace editor target selection 2026-05-19 21:30:43 +07:00
Mohamed Boudra
306601a0c3 Add daemon-wide system prompts (#1100)
* Add daemon-wide system prompt setting

* Align settings textarea rendering

* Polish daemon settings layout

* Remove orchestrator prompt injection
2026-05-19 14:24:56 +00:00
Mohamed Boudra
da0e94fd18 Add DeepSeek TUI to the ACP catalog (#1096)
* Consolidate provider selection handling

* List DeepSeek TUI in docs and website
2026-05-19 22:20:09 +08:00
Mohamed Boudra
84fb7e9e06 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-19 20:49:46 +07:00
Mohamed Boudra
5dd6b030f2 Streamline model selector provider data 2026-05-19 20:01:56 +07:00
Mohamed Boudra
bc7798af28 Allow ACP providers without model lists 2026-05-19 20:01:56 +07:00
Mohamed Boudra
f238cbc20c Tighten DeepSeek TUI catalog copy 2026-05-19 20:01:56 +07:00
Mohamed Boudra
41c5eea678 Add DeepSeek TUI to ACP catalog 2026-05-19 20:01:56 +07:00
Mohamed Boudra
352e8fb6eb Show catalog provider icons (#1098) 2026-05-19 20:54:33 +08:00
Mohamed Boudra
8d65f8bc96 Suggest daemon upgrade on unknown RPC errors 2026-05-19 19:47:53 +07:00
Mohamed Boudra
2c561536e4 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-19 18:12:20 +07:00
Mohamed Boudra
3743df09e6 Add rename for workspaces, terminals, and agent tabs (#531)
* Add rename for workspaces, terminals, and agent tabs

Surfaces rename via the sidebar workspace kebab (git branch rename with
client-side slugify), the terminal tab context menu (stops OSC 2 auto-title
overrides), and the agent tab context menu (locks the title against the
metadata generator). A shared RenameModal wraps AdaptiveModalSheet and
replaces the inline rename on the host page.

Auto-title races are closed from both sides: AgentManager gains
setGeneratedTitleIfUnset with an atomic per-agent write queue, and
TerminalSession replaces lockedTitle with a titleMode discriminated union
so user rename flips to manual and disposes the OSC subscription.

New WebSocket messages are additive: rename_terminal_request/response and
checkout_rename_branch_request/response (branch rename uses the
CheckoutError family). A new @getpaseo/server/utils/branch-slug subpath
export shares slugify + validateBranchSlug between server and app.

* Tighten rename feature and restore lost rebase wiring

Unslop pass on the rename commit plus two rebase artifacts:

- session.ts: collapse handleRenameTerminalRequest to a respond helper
  with early returns; restore workspaceGitWatchTargets.set() in
  syncWorkspaceGitObserver (lost during rebase onto main, which broke
  onBranchChanged firing on branch rename).
- terminal.ts: remove DA1 query handler accidentally re-introduced by the
  rebase (main intentionally removed it); restore conditional
  onTitleChange registration under titleMode === "auto".
- rename-modal.tsx: drop unconfident optional-call on setNativeProps and
  the unknown-cast HTMLInputElement narrowing dance.
- sidebar-workspace-list.tsx: remove unused branch field from rename
  result; flatten validateRenameSlug into early returns.
- host-page.tsx: drop ?? "" fallback on a typed-string field.

* Fix typecheck after rebase: pass parsed config to getScriptConfigs

main refactored getScriptConfigs to take the parsed paseo.json config
instead of a repo path. spawnWorktreeScripts (added in the rename
feature) was still passing repoRoot. Read and parse the config first,
matching how spawnWorkspaceScript already does it.

* Resolve lint regressions and restore lost rebase fixes

Post-rebase cleanup: drop the await on syncWorkspaceGitObservers so
fetch_workspaces emits the response before any cold registration-
triggered git work fires (workspace.id is already on the descriptor —
no registry lookup needed). Restore the DA1 CSI handler that answers
\x1b[?62;4;22c on the daemon-side xterm so foreground apps like nvim
get a reply on stdin. Extract WorkspaceTabRenameModal/useWorkspaceTabRename
to drop WorkspaceScreenContent below the cyclomatic-complexity ceiling.
Switch session test internals to handleMessage so we exercise the public
dispatcher and avoid casting through any. Convert literal 'type' aliases
to interfaces and stop spawning callbacks inline so the codebase passes
the post-rebase oxlint rules.

* Fix typecheck after rebase: wire workspaceGitService and worker setTitle

Bootstrap was missing workspaceGitService when constructing
CreatePaseoWorktreeWorkflowDependencies after main's worktree workflow
refactor. Worker terminal manager needed setTitle on the session and
setTerminalTitle on the manager to satisfy the rename additions to
TerminalSession/TerminalManager interfaces.

* Format session.test.ts after rebase

* Remove stray auto-spawn of workspace scripts after bootstrap

The rebase brought in a spawnWorktreeScripts helper and a call inside
runWorktreeSetupInBackground that auto-started every configured workspace
script after worktree setup completed. main never auto-started scripts —
this regressed the workspace-setup-streaming Playwright test, which
expects the "web" script to be idle so the user can click Run.

Drops the helper, the call, and the workspaceGitService dep that only
existed to feed it.

* Fix checkout branch rename tests

* Fix sidebar checkout action store import

* Skip POSIX terminal tests on Windows

* Unslop the rename-entities feature

Six audit findings closed and ~450 net lines trimmed from the branch:

- Remove the duplicate "rename-branch" union member in
  GitMutationRefreshReason.
- Fold dispatchStashMessage back into handleSessionMessage; the split was a
  feature-first artifact of adding checkout_rename_branch_request, with no
  documented rationale.
- Drop the protected beforeGeneratedTitleIfUnsetWrite test seam from
  AgentStorage; rewrite the race test to exercise real Promise.all
  concurrency against the existing per-agent write queue.
- Reshape useWorkspaceTabRename so the hook returns state and handlers
  only; promote WorkspaceTabRenameModal to an exported component the
  consumer renders directly.
- Rename RenameModal to AdaptiveRenameModal so it reads as a generic
  primitive next to AdaptiveModalSheet, and update host-page,
  sidebar-workspace-list, and the workspace tab rename hook to import it
  by its new name.
- Trim duplicate matrix coverage and Zod self-tests across
  rename-modal.test.tsx, terminal.test.ts, session.test.ts,
  agent-storage.test.ts, messages.rename-entities.test.ts and the three
  rename e2e specs; introduce packages/app/e2e/helpers/rename.ts to share
  setup across the e2e specs without merging coverage.

Behavior of the rename feature is unchanged; targeted vitest, branch-wide
typecheck, and lint all green.

* Restore agentMetadataMocks dropped during rebase

5e6aeb2d removed the agentMetadataMocks hoisted definition and its
vi.mock wiring when it deleted the import describe block. The block
was kept (it belongs to main's import feature) but the mock support
was left behind.

* Fix terminal-manager tests using hardcoded /tmp on Windows

setTerminalTitle tests used cwd: "/tmp" which is not a valid directory
on Windows, causing node-pty error code 267 (ERROR_DIRECTORY). Use
realpathSync(tmpdir()) like the rest of the test file.

* Fix typecheck and lint after rebase onto main

AdaptiveModalSheet moved from a `title` string prop to a structured
`header: SheetHeader`; update AdaptiveRenameModal to memoize and pass
a SheetHeader. invalidateCheckoutGitQueriesForClient moved from
git/actions-store to git/query-keys. useWorkspaceTerminals now owns
the terminal query, so workspace-screen pulls queryKey from the hook
and re-declares queryClient via useQueryClient.

* Update agent metadata test mock to match setGeneratedTitleIfUnset rename

The branch renamed AgentManager.setTitle to setGeneratedTitleIfUnset
for the rename feature; the generateTitlePromptWithConfig helper still
mocked the old method, so eight prompt-byte tests crashed with
"setGeneratedTitleIfUnset is not a function" on both ubuntu and
windows server-tests jobs. Sister mocks in the same file were already
on the new name.

* Fix rename modal showing empty input by using controlled TextInput

AdaptiveTextInput (introduced on main by 29ce6653f) is intentionally
uncontrolled: it drops the `value` prop and seeds the native input
once via `initialValue`. AdaptiveRenameModal still passed `value=
draft` from the pre-rebase shape, so every rename modal opened with
an empty textbox and a disabled Save button. Three playwright e2e
specs (settings-host-page, sidebar-workspace-rename, workspace-agent-
tab-rename) failed for this reason.

Switch the rename modal to a plain controlled TextInput so the input
seeds with the current label and the slug transform (used by sidebar
workspace rename) reflects live in the textbox as the user types.
The unit test's old AdaptiveTextInput mock is replaced with a
react-native mock providing a controlled TextInput shim that captures
the same onChangeText/onSubmitEditing handlers.

* Revert "Fix rename modal showing empty input by using controlled TextInput"

This reverts commit 6283deae842b8a5fed63cb8bf06c78029f98386e.

* Seed rename modal input via AdaptiveTextInput's initialValue + resetKey

AdaptiveTextInput is intentionally uncontrolled — it drops `value` and
seeds the native input once with `initialValue`. The rename modal was
passing `value={draft}` from the pre-rebase shape, so every rename
modal opened with an empty textbox (three playwright specs failing).

Pass `initialValue={draft}` and bump `resetKey` only when the
transform rewrote what the user typed. That seeds the native input
with the current label on open, lets the user keep typing inside
text without cursor jumps (no remount when transform is a no-op),
and remounts the native input with the slug when the transform
diverges so live slugification keeps working in sidebar workspace
rename. Keeping AdaptiveTextInput preserves the BottomSheetTextInput
swap on mobile so the keyboard stays above the sheet — using a plain
TextInput would break that.

The unit test mock is updated to read `initialValue`/`resetKey` so it
mirrors production behavior instead of pretending the input is
controlled.

* Slugify branch rename at submit only, drop live transform

The rename modal's transform prop remounted the native input every
time the slug diverged from what the user typed, which lost focus
mid-edit on every uppercase letter or space in the sidebar workspace
rename. Live-rewriting what the user typed was also surprising — the
expectation is that you type a name, the daemon stores a slug.

Drop the transform prop from AdaptiveRenameModal entirely. Sidebar
workspace rename now slugifies once in handleSubmitRename before
calling renameBranch, and validateRenameSlug runs validateBranchSlug
against the slugified value so the user sees inline errors. The
playwright spec drops the live-slug assertion; it still verifies the
post-submit branch on disk and the rename request payload.

* Rename new rename RPCs to dotted convention

docs/rpc-namespacing.md says new RPCs use dotted names with the
direction as the final segment, and explicitly bans new flat snake_
case names. This PR introduced two flat ones; rename them in place
before the protocol ships:

- checkout_rename_branch_request → checkout.rename_branch.request
- checkout_rename_branch_response → checkout.rename_branch.response
- rename_terminal_request → terminal.rename.request
- rename_terminal_response → terminal.rename.response

Touched: the Zod literals in messages.ts, the discriminated union
entries, the session dispatcher and its tests, the daemon client
wrappers and their tests, the terminal session controller, and the
message-parsing rename-entities test. No callers exist outside the
server package — app and CLI go through the daemon-client wrappers,
which now emit the dotted names.

* Match rename modal Save button and input focus to app conventions

Save uses Button variant=default so it gets the same accent
background + white text as every other primary action in the app
(open-project, settings host save, pair-link confirm, etc).

The input previously fell back to the browser's blue focus outline
on web because the rename modal never set outlineStyle: none — every
other AdaptiveTextInput call site that wants accent feedback already
does this. Kill the browser outline, track focus state, and switch
borderColor to accent while focused.

* Theme the focus-visible outline color via AdaptiveTextInput

public/index.html paints a 2px :focus-visible outline on every web
element with a hard-coded #20744a (Paseo green). On the Claude theme,
the rename modal's input got that green ring instead of the theme's
brown accent — visible mismatch against every other accent-colored
control (primary buttons, etc).

Add an outlineColor entry to AdaptiveTextInput's stylesheet sourced
from theme.colors.accent. Unistyles' Babel plugin tracks the read and
updates the native ShadowTree on theme switch — no React re-render,
no useUnistyles() call (forbidden on this hot path per docs/unistyles.md).
The inline outline-color on the rendered DOM input overrides the
selector-level color from index.html; outline-width/style/offset
still come from the global rule.

Consumer style merges in after, so existing callers that pass
outlineColor (message-input, review/surface, question-form-card)
still win.

Also drop the half-baked isFocused/focusedBorderStyle local state
I added to rename-modal earlier — the AdaptiveTextInput fix removes
the need for it.

* Drop useUnistyles() from rename modal

The rename modal only read theme.colors.foregroundMuted to pass as
the TextInput's placeholderTextColor prop. The rest already went
through StyleSheet.create((theme) => ...). Per docs/unistyles.md the
hook is forbidden when an alternative exists — and this is exactly
the alternative the rest of the codebase uses (project-settings-screen,
add-host-modal, pair-link-modal, command-center): put the muted color
in a tiny StyleSheet entry and read .color off it for the prop.

* Default placeholderTextColor in AdaptiveTextInput

placeholderTextColor was being duplicated at every AdaptiveTextInput
call site (add-host-modal, command-center, pair-link-modal,
project-picker-modal, provider-diagnostic-sheet, combobox, the
sheet's own search inputs, etc.) — all passing the same
theme.colors.foregroundMuted. The shared input should own this.

Move it into AdaptiveTextInput as a default sourced from the same
StyleSheet.create(theme => ...) block as the accent outline. Consumers
still override via the prop if they need a different color. Strip
the redundant placeholderTextColor and local placeholderColor style
entry from rename-modal; other call sites can be cleaned up in a
follow-up.

* Update rename e2e specs to use dotted RPC type strings

Earlier commit (5d5624943) renamed the new rename RPCs to the dotted
convention but missed the two e2e specs that capture the WebSocket
frames by type. captureWsSessionFrames matched the old flat names, so
renameRequests / renameFrames stayed empty even though the rename
went through end to end — the sidebar updated and the branch was
renamed on disk. The toBeGreaterThan(0) assertion fired and the test
failed in playwright CI.
2026-05-19 18:05:45 +08:00
Mohamed Boudra
0a2307d199 Avoid duplicate Claude result text (#1095) 2026-05-19 17:43:26 +08:00
Mohamed Boudra
0f6641c8c0 Show resolved file paths in agent file-link tooltips (#1088)
* fix: show resolved file paths in agent file-link tooltips

Bare filenames in agent messages now resolve to their full workspace
path on hover. Also raises the daemon's directory-suggestion scan
depth so files in deeper package layouts are reachable.

* fix(app): keep file-link wrapper stable to avoid layout shift on resolve

* fix(app): resolve file links on hover instead of at render time

useQuery was firing the daemon RPC for every ambiguous file reference
the moment a message rendered, fanning out a wave of requests on chat
scroll. Switch to enabled: false + prefetchQuery on hover so RPCs are
driven by user intent. Sync-resolvable refs (directFile, external)
still seed via initialData and render with no RPC.

Also memo on source primitives rather than identity so identical-
content sources constructed inline upstream don't bust the memo every
render.

* Redesign assistant file link resolution

* Stabilize assistant file link handlers
2026-05-19 15:32:48 +08:00
Mohamed Boudra
fdecd75f94 Add Claude project-dir resolver matching SDK encoding
Claude's Agent SDK encodes a cwd into ~/.claude/projects/<dir> by
realpath + NFC normalizing the path, replacing every non-alphanumeric
character with "-", and (when the encoded result exceeds 200 chars)
appending a base-36 hash. paseo's existing sanitizer only replaces
five characters and skips realpath, so it misses the SDK's directory
whenever the cwd has a symlink (macOS /var → /private/var), spaces,
parens, NFD unicode, or exceeds the length cap.

This adds the resolver alongside the existing logic; wiring it into
agent.ts to replace resolveHistoryPath is the next step. Tests assert
parity by writing a session file at our computed path and asking the
SDK's getSessionInfo({ dir }) to find it — if both encoders agree,
the SDK finds it.
2026-05-19 13:42:24 +07:00
paseo-ai[bot]
b41cb72da0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-19 06:00:14 +00:00
Yurui Zhou
cb96485035 feat(server): upgrade embedded Pi SDK (#1087)
* feat(server): upgrade embedded Pi SDK

* fix: sync package-lock.json with package.json

The Pi SDK upgrade commit (63e18a9d) regenerated package-lock.json in a
way that dropped packages/website's react@19.2.6, react-dom@19.2.6, and
scheduler@0.27.0 (website depends on react ^19.1.4, which resolves to
19.2.6). This broke `npm ci` in CI, failing all 14 checks at the install
step with EUSAGE "package.json and package-lock.json not in sync".

Regenerated with `npm install`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:56:32 +08:00
ezra
ce822f989f Fix Codex Microsoft Store binary detection on Windows (#1020)
Fixes #1013
2026-05-19 05:12:55 +00:00
Bolun Zhang
e38d0e0fa9 feat(mcp): consolidate provider settings tools (#1011)
Closes #984
2026-05-19 12:56:03 +08:00
João Sousa Andrade
bc32b16b02 Reject relay re-handshake key changes (#1037)
Closes #366
Closes #368
2026-05-19 12:38:55 +08:00
Mohamed Boudra
b2cdbdaed8 Hide import pill once the draft is submitting 2026-05-19 10:26:13 +07:00
Mingyang Sun
9ef7230417 Add Kiro CLI to ACP provider catalog
Adds Kiro CLI as an opt-in ACP provider catalog entry, plus a generic ACP extension-notification sink so unknown notifications no longer fail JSON-RPC dispatch.
2026-05-19 03:23:57 +00:00
Zexin Yuan
383b380d8a fix(cli): query daemon for status and pairing offer over RPC
Closes #1081
2026-05-19 03:15:52 +00:00
Mohamed Boudra
339ca2fc83 Make zinc accent monochrome and respect accentForeground 2026-05-19 10:01:39 +07:00
xy-plus
8ff63a6d71 fix(server): keep Codex sub-agent running on transient child error state
Fixes #1071
2026-05-19 10:57:55 +08:00
Yurui Zhou
aaabadb04b fix: prevent macOS desktop unlock freeze after display sleep (#745)
* Restart the GPU process to recover from macOS compositor freezes

macOS display sleep can leave Chromium's GPU-process display link stuck
on a stale display, so the compositor stops producing frames and the
window looks frozen — unresponsive to clicks and keys — even though the
renderer stays alive.

setupDarwinCompositorWatchdog polls the renderer for frame production
and, on a sustained stall while the window is visible and unlocked,
restarts the GPU process so Chromium rebuilds the display link. This
replaces setupDarwinPaintRefresh, whose invalidate/resize nudges did
not address the dead display link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Give compositor watchdog a module home

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-19 10:41:55 +08:00
Mohamed Boudra
501dcf373b Hide voice mode while agents run 2026-05-19 08:18:56 +07:00
Samatar
5a1c7f266c fix(server): render non-ASCII filenames in git output
Fixes #436
2026-05-19 01:16:33 +00:00
Samatar
9bc6210823 fix(terminal): send SIGINT for hardware Ctrl+C on iPad
Fixes #1049
2026-05-19 09:02:48 +08:00
paseo-ai[bot]
8aa8f7a0cc fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 11:04:02 +00:00
Mohamed Boudra
63fe5dd0df chore(release): cut 0.1.78 2026-05-18 17:59:57 +07:00
Mohamed Boudra
1b9861846c Improve 0.1.78 changelog and add user-facing examples to release docs 2026-05-18 17:58:49 +07:00
Mohamed Boudra
68bce623c8 Route OpenCode live tests through OpenRouter
Big Pickle's models.dev metadata changed upstream to route via the
Anthropic SDK against the Zen OpenAI-compatible endpoint, which the
endpoint rejects. Swap the live-turn smoke tests to
openrouter/~openai/gpt-mini-latest and gate them on OPENROUTER_API_KEY
so fork PRs skip cleanly when the secret is absent. Also unpin
opencode-ai so CI tracks the latest CLI.
2026-05-18 17:16:57 +07:00
Mohamed Boudra
2072265b98 Stabilize OpenCode provider unit tests 2026-05-18 16:08:46 +07:00
Mohamed Boudra
f7ef0e0b84 Add 0.1.78 changelog 2026-05-18 16:08:21 +07:00
Mohamed Boudra
0419346d6a Pin OpenCode CLI in CI 2026-05-18 16:01:30 +07:00
paseo-ai[bot]
2866a12984 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 08:32:53 +00:00
Mohamed Boudra
120f5d94a9 Fix compact web sheet swipe crashes 2026-05-18 15:29:01 +07:00
Mohamed Boudra
29ce6653fd Improve mobile selectors and sheet inputs
Keep sheet text inputs native-owned to avoid React Native controlled input flicker during fast typing. Add reset keys for fields that need programmatic clears, and move model, mode, and thinking pickers onto shared combobox rows with fuzzy ranked search and native virtualization.
2026-05-18 14:56:17 +07:00
Mohamed Boudra
028839b3cb Add comparison pages under /docs/alternatives
Group the docs sidebar by directory so the new pages cluster
under a heading, add a hamburger menu for the mobile docs nav,
and let the docs main column shrink so wide code blocks and
tables scroll inside instead of pushing the page wide.
2026-05-18 12:59:04 +07:00
Mohamed Boudra
82319f5805 Keep agent panes mounted and focused 2026-05-18 11:24:56 +07:00
Mohamed Boudra
69fc6fe754 Document the positive submission-verification path (eas build:view Logs URL) 2026-05-18 10:16:42 +07:00
Mohamed Boudra
1b2a28be47 Drop eas submit:list references from release/android docs 2026-05-18 10:15:10 +07:00
Mohamed Boudra
665d9cedb5 Drop bogus eas submit:list from release docs and heartbeat checks 2026-05-18 10:14:29 +07:00
Mohamed Boudra
0d0012959a Lock babysit pattern: heartbeat to self, every 15m maxRuns 8 2026-05-18 09:59:00 +07:00
Mohamed Boudra
77b92b58f7 Drop trailing periods from changelog and fix mobile release docs
- Strip end-of-bullet periods from CHANGELOG.md (whole file)
- Tighten 0.1.77 TLS bullet to drop LAN/public explanation
- Release docs: drop release-mobile.yml references (workflow does not exist),
  add EAS mobile-build babysitting section using the EAS CLI, clarify that
  releases are always patch, clarify that "stable" means stable
- Changelog style guide: no trailing periods, one sentence per bullet, lead
  with capability not mechanism
2026-05-18 09:33:46 +07:00
paseo-ai[bot]
d4ebf1815c fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 02:26:22 +00:00
1298 changed files with 95547 additions and 40356 deletions

View File

@@ -0,0 +1,11 @@
---
name: release-beta
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move, npm only on the beta dist-tag.
user-invocable: true
---
# Release beta
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rules the doc enforces — betas don't touch `CHANGELOG.md`, don't draft release notes, and publish npm only with the explicit `beta` dist-tag.

View File

@@ -0,0 +1,11 @@
---
name: release-stable
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
user-invocable: true
---
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.

1
.claude/skills/release-beta Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/release-beta

View File

@@ -0,0 +1 @@
../../.agents/skills/release-stable

View File

@@ -69,18 +69,18 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Build server stack
run: npm run build:server
- name: Typecheck all packages
run: npm run typecheck
- name: Verify public package contents
run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
strategy:
fail-fast: false
@@ -107,17 +107,15 @@ jobs:
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependencies
run: npm run build:server-deps
- name: Run server tests
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
strategy:
@@ -136,14 +134,8 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Build server stack
run: npm run build:server
- name: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop
@@ -164,12 +156,37 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build app dependencies
run: npm run build:app-deps
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
sdk-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build client dependencies
run: npm run build:client
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
- name: Run client tests
run: npm run test --workspace=@getpaseo/client
- name: Typecheck client examples
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
runs-on: ubuntu-latest
steps:
@@ -186,14 +203,11 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build app dependencies
run: npm run build:app-deps
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Build server stack
run: npm run build:server
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
@@ -227,7 +241,7 @@ jobs:
run: npm ci
- name: Build relay
run: npm run build --workspace=@getpaseo/relay
run: npm run build:relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
@@ -253,9 +267,6 @@ jobs:
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
env:

View File

@@ -28,8 +28,8 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build app dependencies
run: npm run build:app-deps
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/app

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- "CHANGELOG.md"
- "public-docs/**"
- "packages/website/**"
- "package.json"
- "package-lock.json"

View File

@@ -183,6 +183,14 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-macos-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*.dmg
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
@@ -273,6 +281,14 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-linux
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
@@ -360,6 +376,14 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-windows
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4

View File

@@ -10,6 +10,8 @@ on:
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, and OpenCode.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
## Repository map
@@ -32,9 +32,11 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
@@ -70,8 +72,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
- `npm run build:client` — rebuild protocol and client declarations.
- `npm run build:server` — rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale.
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:

View File

@@ -17,9 +17,12 @@
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
<a href="https://www.reddit.com/r/PaseoAI/">
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
</a>
</p>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
@@ -34,7 +37,7 @@
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
@@ -49,7 +52,9 @@ You need at least one agent CLI installed and configured with your credentials:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
### Desktop app (recommended)
@@ -129,8 +134,8 @@ npm run dev:app
npm run dev:desktop
npm run dev:website
# build the daemon
npm run build:daemon
# build the server stack
npm run build:server
# repo-wide checks
npm run typecheck

View File

@@ -46,6 +46,8 @@ By default, the daemon binds to `127.0.0.1`. With no password configured, the lo
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.

View File

@@ -14,19 +14,21 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
## Relationships
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
1. Snapshot the current session into the registry
@@ -52,9 +54,15 @@ Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-onl
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
## Workspace activity
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
## The subagents track
The collapsible section above the composer in an agent's pane (`packages/app/src/subagents/subagents-section.tsx`). Membership rule (`packages/app/src/subagents/subagents.ts`):
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
```
parentAgentId === thisAgent.id AND !archivedAt
@@ -75,12 +83,6 @@ We considered universal decoupling (no tab close ever archives, archive is alway
## Limitations
### Detached agents are cascade-archived
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
@@ -97,11 +99,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | ------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.

View File

@@ -38,6 +38,10 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release
rm -rf android
```
### React version lockstep
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
## Screenshots
```bash
@@ -48,24 +52,28 @@ adb exec-out screencap -p > screenshot.png
Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. Both workflows also support `workflow_dispatch`; the GitHub APK one takes an existing `tag` input so you can rebuild without cutting a new tag.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
### Useful commands
```bash
cd packages/app
# List recent workflow runs
npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Recent builds
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Inspect a run
npx eas workflow:view <run-id>
# Stream logs for a failed job
npx eas workflow:logs <job-id> --non-interactive --all-steps
# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page,
# which has a Submissions section showing the auto-submit to the Play Store).
npx eas build:view <build-id>
```
The Play Console (Internal testing → Production tracks) is the final confirmation that the binary reached the store.
See [docs/release.md](release.md) for the full mobile-build babysitting flow.

View File

@@ -22,13 +22,13 @@ Your code never leaves your machine. Paseo is local-first.
│ (Node.js) │
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Claude │ │ Codex │ │ OpenCode
│ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ │
└───────────┘ └────────┘ └──────────┘
┌────────────┼────────────┬────────────┬────────────
│ │ │ │ │
┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐
│ Claude │ │ Codex │ │ Copilot │ │ OpenCode │ │ Pi
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ ACP │ │ │ │
└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────
```
## Components at a glance
@@ -68,9 +68,20 @@ All paths are under `packages/server/src/`.
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
| `shared/messages.ts` | Zod schemas for the entire wire protocol |
| `shared/binary-frames/` | Terminal stream and file transfer binary frame codecs |
### `packages/protocol` — Wire schemas and shared protocol types
The source of truth for WebSocket messages, binary frame codecs, endpoint parsing,
agent timeline types, provider config schemas, and other values shared by daemon
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
it does not depend on the server.
### `packages/client` — Daemon client library and SDK facade
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
### `packages/app` — Mobile + web client (Expo)
@@ -79,7 +90,9 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
### `packages/cli` — Command-line client
@@ -125,7 +138,7 @@ TanStack Router + Cloudflare Workers. Serves paseo.sh.
## WebSocket protocol
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/server/src/shared/messages.ts`.
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/protocol/src/messages.ts`.
**Handshake:**
@@ -146,6 +159,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
@@ -168,6 +183,8 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
- 1-byte slot: terminal slot id
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
### Compatibility rules
@@ -208,23 +225,24 @@ initializing → idle ⇄ running
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to all subscribed clients in real time
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
## Agent providers
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
The three first-class, user-facing providers are Claude Code, Codex, and OpenCode. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
| Provider | Wraps | Session format |
| ------------------ | ------------------------------------ | -------------------------------------------------- |
| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| Copilot | GitHub Copilot via ACP | Provider-managed |
| OpenCode | OpenCode server / CLI | Provider-managed |
| Cursor / Copilot | ACP wrapper (`acp-agent`) | Provider-managed |
| Cursor | ACP wrapper (`acp-agent`) | Provider-managed |
| Generic ACP | ACP wrapper | Provider-managed |
| Pi-direct | Direct Anthropic API call | Stateless |
| Pi | Local Pi RPC process | Provider-managed |
| Mock load test | In-process fake | In-memory |
All providers:

View File

@@ -24,6 +24,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
- [Extending a built-in provider](#extending-a-built-in-provider)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
- [Custom binary for a provider](#custom-binary-for-a-provider)
- [Disabling a provider](#disabling-a-provider)
@@ -59,29 +60,7 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
### Codex with an OpenAI-compatible endpoint
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) below for the dedicated Codex example.
---
@@ -206,6 +185,62 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
---
## Codex with a custom OpenAI-compatible endpoint
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
### Setup
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"description": "Codex via custom OpenAI-compatible endpoint",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
### What Paseo wires up
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
```toml
model_provider = "my-codex"
[model_providers.my-codex]
name = "My Codex"
base_url = "https://custom-relay.example.com/v1"
wire_api = "responses"
env_key = "OPENAI_API_KEY"
requires_openai_auth = false
```
- `base_url` — taken from `OPENAI_BASE_URL`. If it does not already end in `/v1`, Paseo appends `/v1`. Trailing slashes are stripped.
- `wire_api` — always `"responses"` (OpenAI Responses API protocol).
- `env_key` — set to `"OPENAI_API_KEY"` when that env var is present and non-empty, so Codex reads the key from the same env var Paseo passes through.
- `requires_openai_auth` — forced to `false` when `OPENAI_API_KEY` is provided, so Codex skips its built-in OpenAI login flow.
### Notes
- The endpoint must speak the OpenAI **Responses API**, not just chat completions. Many gateways (OpenRouter, LiteLLM) support both — pick the Responses-compatible route.
- Set `models` explicitly. Custom endpoints expose their own model IDs (`anthropic/claude-opus-4-7`, `qwen/qwen3-coder`, `local/llama`, etc.), and Paseo does not discover them automatically for Codex.
- To run multiple endpoints side-by-side, define multiple entries that each extend `"codex"` with different IDs, labels, and env. Each appears as its own provider in the app.
- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit `OPENAI_BASE_URL` and use `command` instead — see [Custom binary for a provider](#custom-binary-for-a-provider).
---
## Multiple profiles for the same provider
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
@@ -339,6 +374,8 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
### Adding a generic ACP provider
Set `extends: "acp"` and provide a `command`:

View File

@@ -30,7 +30,7 @@ $PASEO_HOME/
└── push-tokens.json # Expo push notification tokens
```
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`.
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.
---
@@ -139,6 +139,7 @@ Single file, validated with `PersistedConfigSchema`.
listen: "127.0.0.1:6767",
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
mcp: { enabled: boolean, injectIntoAgents: boolean },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
auth: { password: string } // bcrypt hash, optional
@@ -146,6 +147,9 @@ Single file, validated with `PersistedConfigSchema`.
app: {
baseUrl: string
},
worktrees?: {
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }
@@ -154,7 +158,10 @@ Single file, validated with `PersistedConfigSchema`.
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
providers: Record<providerId, ProviderOverride>
providers: Record<providerId, ProviderOverride>,
metadataGeneration: {
providers: [{ provider, model?, thinkingOptionId? }]
}
},
features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
@@ -170,13 +177,17 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
---
## 3. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
| ----------- | ------------------------------------- | -------------------------------- |
@@ -198,7 +209,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
### Nested: ScheduleTarget (discriminated union on `type`)

View File

@@ -94,6 +94,8 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.

View File

@@ -43,6 +43,92 @@ In any worktree-style or portless setup, never assume default ports.
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### React render profiling
The app has a gated React render profiler in
`packages/app/src/utils/render-profiler.tsx`. Wrap the component boundary you want
to measure with `RenderProfile`, then open the app with `?renderProfile=1`. When
the query param is absent, `RenderProfile` returns children directly and records
nothing.
Captured samples are exposed on `globalThis.__PASEO_RENDER_PROFILE__`. Call
`globalThis.__PASEO_RESET_RENDER_PROFILE__?.()` after warm-up and before the
interaction you want to measure. If a memo comparator or subscription boundary
needs explanation, call `recordRenderProfileReasons(id, reasons)` while profiling;
reason counts are exposed on `globalThis.__PASEO_RENDER_PROFILE_REASONS__`.
Use this workflow for any render investigation:
1. Add stable `RenderProfile` boundaries around the suspected root and expensive
children. Keep IDs specific enough to compare before and after.
2. Reproduce against real app state, not toy fixtures, whenever practical.
3. Record an idle baseline first. If idle is noisy, fix or account for that
before optimizing the interaction.
4. Warm up the route, reset profiler samples, run the exact interaction, then
compare `actualDuration`, render counts, and per-commit samples.
5. When a memo boundary still renders, record reasons before changing code. Do
not guess from object identity alone.
6. Keep changes that move the measured profile. Remove probes or memo wrappers
that do not move the number.
What this caught during the workspace tab investigation:
- A large apparent workspace cost was real interaction work, not daemon noise;
the idle baseline stayed near zero.
- The expensive stream rerender was mostly prop identity churn from pane context
callbacks and capability objects, not new stream data.
- Stabilizing provider actions at the pane boundary helped because every mounted
panel consumes that context.
- Comparing value-shaped capability flags beat preserving object identity through
unrelated stores.
- Some plausible fixes did not pay off: memoizing the tab row and composer draft
object barely moved the profile, so they were removed.
Existing scenario script: workspace agent/terminal tab switching. Start Expo on
web, keep a daemon available, then run:
```bash
PASEO_PROFILE_SERVER_ID=<server-id> \
PASEO_PROFILE_WORKSPACE_ID=<workspace-path> \
PASEO_PROFILE_AGENT_ID=<agent-id> \
npm run profile:workspace-tabs --workspace=@getpaseo/app
```
This script opens the app with `?renderProfile=1`, creates a temporary terminal
tab, switches between a real agent and that terminal, prints aggregated React
Profiler timings, then removes the temporary terminal. It is an example of the
workflow above, not the only way to use the profiler. Useful knobs:
```bash
PASEO_PROFILE_APP_URL=http://localhost:19010 # Expo web URL
PASEO_PROFILE_SWITCH_COUNT=1 # number of agent/terminal switch pairs
PASEO_PROFILE_SWITCH_WAIT_MS=250 # delay after each click
PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples
```
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
source that drives frame production — stuck on a stale display. The compositor
then stops producing frames and the window looks frozen: unresponsive to clicks
and keys even though the renderer and every process stay alive. It self-recovers
after a few minutes, which is too long for a foreground app.
`setupDarwinCompositorWatchdog`
(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against
this. It polls the renderer for frame production every couple of seconds and,
after a sustained stall while the window is visible and unlocked, restarts the
GPU process so Chromium rebuilds the display link. The probe is skipped while
the screen is locked or the window is hidden or minimized, since a window
legitimately stops producing frames then.
The watchdog deliberately leaves background throttling **enabled**. Calling
`webContents.setBackgroundThrottling(false)` would keep the compositor producing
frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
battery while the app is idle — so do not re-add it. The probe's visibility
guards already prevent throttling from causing a false stall.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
@@ -72,12 +158,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
| Variable | Value |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
@@ -93,23 +181,45 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
}
```
## Build sync gotchas
## Built workspace packages
The daemon and CLI consume sibling workspaces from compiled `dist/` output, not `src/`. When you change a workspace that something else imports, rebuild the producer first or the consumer will speak a stale protocol and fail with handshake warnings, timeouts, or stale type errors.
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
The fastest way to keep this consistent is to rebuild the whole daemon stack with one command:
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
Use the named root build targets instead of remembering workspace dependency chains:
```bash
npm run build:daemon
npm run build:client # protocol -> client
npm run build:server-deps # highlight -> relay -> protocol -> client
npm run build:server # server-deps -> server -> cli
npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-audio
```
This rebuilds, in order, `@getpaseo/highlight``@getpaseo/relay``@getpaseo/server``@getpaseo/cli`. Use it whenever you have changed any of those four and need clean cross-package types or runtime behavior.
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
For tighter loops, you can rebuild a single workspace:
- Changed `packages/relay/src/*`: `npm run build --workspace=@getpaseo/relay` (server imports `@getpaseo/relay` from `dist/*`).
- Changed `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types: `npm run build --workspace=@getpaseo/server` (CLI imports `@getpaseo/server` via package exports resolving to `dist/*`).
- Changed `packages/highlight/src/*`: `npm run build --workspace=@getpaseo/highlight` (server depends on it).
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
- Changed app build dependencies: `npm run build:app-deps`.
## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
and `uvx`) to exact package versions. Run the drift checker regularly — and
before releases — so catalog installs do not sit on stale agent versions:
```bash
npm run acp:version-drift # report stale/non-exact package pins
npm run acp:version-drift:check # same, exits non-zero on drift
npm run acp:version-drift:update # rewrite catalog pins to latest exact versions
```
The checker updates only package-runner catalog entries. Providers that use a
preinstalled binary such as `opencode acp`, `cursor-agent acp`, or `goose acp`
are reported as skipped because their versions are owned by the user's local
install.
## CLI reference
@@ -171,6 +281,20 @@ Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
## App web deploys
`packages/app` exports a single-page Expo web app and deploys the `dist/`
directory to Cloudflare Pages with `npm run deploy:web --workspace=@getpaseo/app`.
PWA install metadata lives in `packages/app/public/manifest.json` and is linked
from `packages/app/public/index.html`. Keep the install icons in `public/` so
Cloudflare serves them from stable root URLs after `expo export`.
Do not add service-worker caching casually. Paseo is a live control surface for
agents, and an aggressive service worker can strand installed users on stale web
code. If offline behavior becomes a product requirement, add it deliberately
with an update strategy and test the installed-app upgrade path.
## Expo troubleshooting
```bash

View File

@@ -0,0 +1,211 @@
# Git Snapshot Startup Reshaping - 2026-05-27
## What changed
The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata.
```text
daemon startup / workspace subscription
-> WorkspaceGitService.refreshSnapshot(cwd)
-> getCheckoutSnapshotFacts(cwd)
-> getCheckoutStatus(cwd, { facts })
-> getCheckoutShortstat(cwd, { facts })
-> getPullRequestStatus(cwd, github, ..., { facts })
-> WorkspaceGitRuntimeSnapshot
-> session workspace descriptor githubRuntime.pullRequest
-> app useSidebarWorkspacesList()
-> SidebarWorkspaceEntry.prHint
-> Sidebar row badge + hover card checks
```
The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row.
## Shared Git Facts
`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions:
- worktree root: `rev-parse --show-toplevel`
- current branch: `rev-parse --abbrev-ref HEAD`
- origin remote URL
- Paseo worktree ownership and stored base ref
- resolved base ref and best comparison base
- main repo root
- branch remote/merge config
- tracked origin branch
- pull request lookup target for fork/PR worktrees
Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them.
## Current Data Flow
```text
Workspace subscription / fetch_workspaces
-> session workspace registry
-> workspaceGitService.getSnapshot(cwd, includeGitHub)
-> refresh queue/throttle/dedupe per normalized cwd
-> refreshGitSnapshot()
-> getCheckoutSnapshotFacts()
-> getCheckoutStatus({ facts })
-> getCheckoutShortstat({ facts })
-> refreshGitHubSnapshot()
-> getPullRequestStatus({ facts })
-> cached WorkspaceGitRuntimeSnapshot
-> WorkspaceDescriptorPayload.gitRuntime
-> WorkspaceDescriptorPayload.githubRuntime
-> app session store
-> useSidebarWorkspacesList()
-> diffStat from descriptor
-> prHint from descriptor.githubRuntime.pullRequest
```
## Startup Benchmark
Added deterministic real-home benchmark:
`packages/server/scripts/benchmark-startup-git-real-home.ts`
The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas.
The frozen home used for the comparison contained 22 workspaces.
### Before/After
| run | code shape | client shape | git commands | failures | elapsed |
| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: |
| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms |
| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms |
| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms |
| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms |
The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran.
The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline).
### Baseline: before change + legacy PR fanout
```json
{
"scenario": "legacyPrFanout",
"workspaceCount": 22,
"elapsedMs": 39039,
"git": {
"total": 529,
"failed": 20,
"maxConcurrent": 8,
"byCommand": [
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 66 },
{ "key": "rev-parse --git-common-dir", "count": 58 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 50 },
{ "key": "rev-parse --git-dir", "count": 36 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 },
{ "key": "config --get remote.origin.url", "count": 32 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 14 },
{ "key": "status --porcelain", "count": 14 }
]
},
"process": {
"cpuUserMs": 2009,
"cpuSystemMs": 2428,
"rssDeltaMb": -1.5,
"heapUsedDeltaMb": 16.9
}
}
```
### After: after change + snapshot-only sidebar
```json
{
"scenario": "snapshotOnly",
"workspaceCount": 22,
"elapsedMs": 31273,
"git": {
"total": 372,
"failed": 15,
"maxConcurrent": 8,
"byCommand": [
{ "key": "config --get remote.origin.url", "count": 35 },
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 34 },
{ "key": "rev-parse --git-common-dir", "count": 31 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
{ "key": "status --porcelain", "count": 22 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 17 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 },
{ "key": "rev-list --count main..origin/main", "count": 7 }
]
},
"process": {
"cpuUserMs": 1871,
"cpuSystemMs": 2152,
"rssDeltaMb": 4.4,
"heapUsedDeltaMb": 8.8
}
}
```
### After 2: shared service-level facts
```json
{
"scenario": "snapshotOnly",
"workspaceCount": 22,
"elapsedMs": 31334,
"git": {
"total": 308,
"failed": 15,
"maxConcurrent": 8,
"byCommand": [
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 31 },
{ "key": "rev-parse --git-common-dir", "count": 26 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "status --porcelain", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "config --get remote.origin.url", "count": 13 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 13 },
{ "key": "rev-parse --absolute-git-dir", "count": 13 },
{ "key": "rev-parse --show-toplevel", "count": 13 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 },
{ "key": "fetch origin --prune", "count": 5 }
]
},
"process": {
"cpuUserMs": 1817,
"cpuSystemMs": 1869,
"rssDeltaMb": 16.7,
"heapUsedDeltaMb": 10.4
}
}
```
## Snapshot Equivalence Guard
Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run:
- `rev-parse --show-toplevel`
- `rev-parse --abbrev-ref HEAD`
Test:
`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads`
## Remaining Waste Visible In Baseline
This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command.
The benchmark still shows repeated per-workspace reads that are candidates for the next pass:
- base ref existence checks still repeat as `show-ref` probes.
- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`.
- repo common-dir lookup is lower, but still above the apparent git workspace count.
- shortstat still runs its own merge-base/diff/untracked scan per workspace.
The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`.

View File

@@ -0,0 +1,389 @@
# OpenCode Provider Snapshot Startup Timeout Diagnosis - 2026-05-27
## Answer
The startup timeout is real OpenCode provider snapshot work, not an agent resume path.
In the dev-style copied-home reproduction, the OpenCode snapshot misses the 30s budget because several expensive things stack:
1. Paseo starts from a copied `PASEO_HOME` containing 4,851 agent records.
2. Clients ask for provider snapshots for three cwd scopes at almost the same time:
- `/Users/moboudra`
- `/Users/moboudra/dev/paseo`
- `/Users/moboudra/dev/blankpage/editor`
3. Each OpenCode snapshot runs two OpenCode SDK calls:
- `GET /provider?directory=...` through `client.provider.list()`
- `GET /agent?directory=...` through `client.app.agents()`
4. One cold `opencode serve` process is shared by the three cwd scopes. It took 8.562s to become ready.
5. After OpenCode was listening, Paseo issued six OpenCode HTTP calls concurrently.
6. The OpenCode `/provider` responses are large: about 3,549,620 decompressed bytes per cwd.
7. During the same window, the daemon was still doing heavy startup workspace git work. In the exact 18:14:19-18:14:43 window, the daemon log has 292 git spawn/close events.
8. The `/provider` calls eventually succeeded, but too late: they completed about 32.2s-32.5s after the snapshot fetch started, while the snapshot timeout is 30s.
So the root cause is:
```text
Cold OpenCode server startup + three concurrent cwd snapshots + large OpenCode /provider responses + daemon startup git contention causes client.provider.list() to complete after Paseo's 30s snapshot budget.
```
More precise wording: the contention is machine-level process/CPU/filesystem contention created by daemon startup work, especially git work. It is not proven to be an OpenCode internal lock or a Paseo-only event-loop issue. A daemon-free repro with only OpenCode plus an external git storm slowed the same six OpenCode calls from about 1s to about 30s total.
Manual settings refresh works because it runs after startup contention is gone and uses `force: true`, which creates fresh OpenCode runtime/server state. The same OpenCode provider refreshes then complete in about 1.7s-2.2s.
The daemon does not auto-retry error snapshots. A failed provider snapshot is cached as `status: "error"` until an explicit refresh resets it to loading.
## Follow-up: Normal Copied-Home Startup Check
I later reran a normal dev-daemon startup against a fresh copy of the same Paseo home metadata and drove the app startup request path:
```text
fetchWorkspaces
fetchAgents
getProvidersSnapshot(home scope)
getProvidersSnapshot(first workspace scope)
```
That run did not reproduce the 30s OpenCode timeout.
```text
home scope:
OpenCode ready at ~8s
availability: 1.6s
fetch total: 5.2s
first workspace scope:
OpenCode ready at ~26s
availability: 2.0s
fetch total: 15.4s
```
The slowest OpenCode operation in that successful run was the workspace-scoped `/provider` response body read: `13.6s`. The daemon log had no `Timed out refreshing OpenCode` entry and no OpenCode provider snapshot failure.
This means the timeout is reproducible under the heavier multi-scope startup contention captured below, but it is not guaranteed on every copied-home dev startup.
## Reproduction Used
The user's correction was right: the useful reproduction is not a random isolated home. It must match `dev.sh` worktree behavior.
Relevant scripts:
- `scripts/dev.sh`
- `scripts/dev-daemon.sh`
- `scripts/dev-home.sh`
`dev-home.sh` only seeds this metadata into the dev home:
```text
agents/**/*.json
projects/**/*.json
config.json
```
It does not copy `chat`, `loops`, `schedules`, sockets, pid files, logs, or worktree contents.
I ran a separate daemon, not the main daemon:
```text
PASEO_HOME=/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/paseo-devseed.Wms6pi
PASEO_LISTEN=127.0.0.1:51116
PASEO_LOG_LEVEL=trace
```
Startup facts:
```text
18:13:39.552 Agent storage initialized: 712ms
18:13:39.559 Workspace registries bootstrapped: 719ms
18:13:39.961 Agent registry loaded: 4851 records
18:13:39.972 Server listening: http://127.0.0.1:51116
```
The probe then connected four client sessions and requested:
- workspaces
- active agents
- provider snapshots for home, paseo, and blankpage/editor
Client-visible result:
```text
18:14:30.263 /Users/moboudra/dev/blankpage/editor opencode error:
OpenCode app.agents timed out after 10s
18:14:41.687 /Users/moboudra/dev/paseo opencode error:
Timed out refreshing OpenCode after 30000ms
18:14:41.688 /Users/moboudra opencode error:
Timed out refreshing OpenCode after 30000ms
```
## Exact OpenCode Timeline
OpenCode snapshot requests began at `18:14:10`.
Availability checks:
```text
18:14:10.780 opencode availability start for /Users/moboudra
18:14:10.787 opencode availability start for /Users/moboudra/dev/paseo
18:14:10.800 opencode availability start for /Users/moboudra/dev/blankpage/editor
18:14:11.363 paseo availability complete: 576ms
18:14:11.376 home availability complete: 597ms
18:14:11.391 blankpage availability complete: 591ms
```
OpenCode server acquisition:
```text
18:14:11.364 OpenCode server spawn start: opencode serve --port 56376
18:14:19.926 OpenCode server listening after 8562ms
```
Six SDK calls were then issued:
```text
18:14:19.931 GET /provider directory=/Users/moboudra/dev/paseo
18:14:19.931 GET /agent directory=/Users/moboudra/dev/paseo
18:14:19.931 GET /provider directory=/Users/moboudra
18:14:19.931 GET /agent directory=/Users/moboudra
18:14:19.931 GET /provider directory=/Users/moboudra/dev/blankpage/editor
18:14:19.936 GET /agent directory=/Users/moboudra/dev/blankpage/editor
```
Why six:
| Cwd | Why that scope exists | Model call | Mode call |
| -------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | --------------------------------- |
| `/Users/moboudra` | home/settings provider snapshot | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
| `/Users/moboudra/dev/paseo` | workspace-scoped provider snapshot for the Paseo workspace | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
| `/Users/moboudra/dev/blankpage/editor` | workspace/agent cwd snapshot for blankpage/editor | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
Multiple clients can request the same snapshot scope during startup, but non-forced provider loads are deduped by `(cwd, provider)`. Different cwd scopes are separate loads. Three cwd scopes times two OpenCode SDK calls each is the six OpenCode calls in this repro.
Headers arrived before the 30s timeout:
| Call | Cwd | Headers after request |
| ----------- | ------------------------------- | --------------------- |
| `/provider` | `/Users/moboudra` | 6.462s |
| `/agent` | `/Users/moboudra` | 6.681s |
| `/agent` | `/Users/moboudra/dev/paseo` | 6.681s |
| `/provider` | `/Users/moboudra/dev/paseo` | 8.192s |
| `/provider` | `/Users/moboudra/dev/blankpage` | 8.654s |
| `/agent` | `/Users/moboudra/dev/blankpage` | 8.649s |
But body consumption and completion lagged:
```text
18:14:29.380 /agent home complete, total app.agents duration 9450ms
18:14:29.813 /agent paseo complete, total app.agents duration 9883ms
18:14:30.263 /agent blankpage timed out at 10s
18:14:31.332 /agent blankpage body finally finished, after the 10s app.agents timeout
18:14:41.687 paseo snapshot outer 30s timeout fires
18:14:41.688 home snapshot outer 30s timeout fires
18:14:43.593 /provider home completes, provider.list duration 23664ms, total listModels 32218ms
18:14:43.798 /provider blankpage completes, provider.list duration 23868ms, total listModels 32411ms
18:14:43.839 /provider paseo completes, provider.list duration 23911ms, total listModels 32476ms
```
The useful `/provider` results arrived about 1.9s-2.2s after the snapshot manager had already marked home and paseo as failed.
## Why Settings Refresh Works
After the daemon settled, I ran the same refresh path through the daemon on port `51116`, using `refreshProvidersSnapshot({ providers: ["opencode"] })`.
Results:
```text
home refresh:
total: 2165ms
status: ready
models: 409
modes: 5
/Users/moboudra/dev/paseo refresh:
total: 1675ms
status: ready
models: 409
modes: 5
/Users/moboudra/dev/blankpage/editor refresh:
total: 1794ms
status: ready
models: 409
modes: 5
```
Trace details for the manual-style refresh:
```text
OpenCode server acquisition: 708ms-1291ms
/agent completion: 433ms-592ms after request start
/provider completion: 524ms-618ms after request start
```
That proves the startup failure is not bad credentials, not a permanently wedged OpenCode install, and not OpenCode generally taking more than 30s. It is startup timing and contention.
## Minimal OpenCode-Only Repros
### OpenCode Only, No Daemon, No Artificial Load
I started a fresh `opencode serve`, waited for stdout `listening on`, then issued the same six HTTP calls concurrently:
```text
GET /provider?directory=/Users/moboudra
GET /agent?directory=/Users/moboudra
GET /provider?directory=/Users/moboudra/dev/paseo
GET /agent?directory=/Users/moboudra/dev/paseo
GET /provider?directory=/Users/moboudra/dev/blankpage/editor
GET /agent?directory=/Users/moboudra/dev/blankpage/editor
```
Three runs:
| Run | `opencode serve` ready | All six calls complete |
| --- | ---------------------- | ---------------------- |
| 1 | 1376ms | 1295ms |
| 2 | 906ms | 1050ms |
| 3 | 939ms | 898ms |
Slowest individual call in those runs:
```text
/provider /Users/moboudra/dev/paseo: 1270ms total
/agent /Users/moboudra/dev/blankpage/editor: 1251ms total
```
So six concurrent OpenCode calls alone are not the bug.
### OpenCode Only Plus External Git Storm, No Daemon
I then ran the same OpenCode-only six-call test while an external shell spawned repeated git commands across the same real workspaces/worktrees. This did not use the Paseo daemon.
Result:
```text
opencode serve ready: 15479ms
all six OpenCode calls complete: 15176ms after server ready
combined cold-start + calls: about 30655ms
```
Individual calls under the external git storm:
| Call | Cwd | Total |
| ----------- | -------------------------------------- | ------: |
| `/provider` | `/Users/moboudra` | 10684ms |
| `/agent` | `/Users/moboudra` | 10767ms |
| `/provider` | `/Users/moboudra/dev/paseo` | 13220ms |
| `/agent` | `/Users/moboudra/dev/paseo` | 13147ms |
| `/provider` | `/Users/moboudra/dev/blankpage/editor` | 14675ms |
| `/agent` | `/Users/moboudra/dev/blankpage/editor` | 15038ms |
This is the daemon-free minimal evidence that process/filesystem contention can push the same OpenCode cold-start + six-call workload to the same 30s boundary.
## Why It Does Not Retry
`ProviderSnapshotManager.getSnapshot()` only starts background warmup for:
- no existing snapshot
- missing providers
- entries still in `loading` with no active load
When refresh fails, `refreshProvider()` stores:
```text
status: "error"
error: "Timed out refreshing OpenCode after 30000ms"
```
An `error` entry is not treated as stale/loading by `getSnapshot()`, so normal reads keep returning the cached error.
Settings refresh calls `refresh_providers_snapshot_request`, which routes to:
```text
refreshSettingsSnapshot()
clearCachedProviders()
resetSnapshotToLoading()
refreshProviders(... force: true)
```
That is why you have to force a manual refresh.
## Git Work During The Repro
This is not the final optimization report, but it matters for the timeout because it overlaps exactly with OpenCode response handling.
Total git commands in the dev-style copied-home daemon log:
```text
632 spawned
632 closed
```
Top cwd counts:
| Count | Cwd |
| ----: | ------------------------------------------------------------------------------------- |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/merry-ladybug` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/hopeful-eel` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` |
| 44 | `/Users/moboudra/.paseo/worktrees/0vpo9h4b/breezy-toad` |
| 36 | `/Users/moboudra/.paseo/worktrees/steering-policy-refactor-detached` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-find-in-pane` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` |
| 24 | `/Users/moboudra/dev/paseo` |
| 24 | `/Users/moboudra/dev/blankpage/editor` |
| 24 | `/Users/moboudra/dev/faro/main` |
| 24 | `/Users/moboudra/dev/konbert/web` |
| 24 | `/Users/moboudra/dev/paseo-cloud` |
In the exact OpenCode pressure window, `18:14:19` through `18:14:43`, there were:
```text
142 git command spawns
150 git command closes
```
The main repeated command shapes were:
```text
76 git rev-parse --show-toplevel
72 git status --porcelain
72 git show-ref --verify --quiet refs/remotes/origin/main
72 git show-ref --verify --quiet refs/heads/main
16 git config --get branch.main.remote
16 git config --get branch.main.merge
16 git rev-list --count main..origin/main
16 git rev-list --count origin/main..main
```
## Original `log.txt` Alignment
The original startup showed the same home and paseo outer timeout shape:
```text
16:04:22.466 /Users/moboudra/dev/paseo:
Timed out refreshing OpenCode after 30000ms
16:04:22.482 /Users/moboudra:
Timed out refreshing OpenCode after 30000ms
```
The original logs did not include SDK fetch/header/body timing, so they could only show the wrapper-level timeout. The dev-style copied-home reproduction with instrumentation now shows the missing link: the `/provider` calls completed just after the 30s snapshot budget.
## Files Instrumented For Diagnosis
Temporary trace instrumentation was added to:
- `packages/server/src/server/agent/provider-snapshot-manager.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/opencode/runtime.ts`
- `packages/server/src/server/agent/providers/opencode/server-manager.ts`
The instrumentation is behavior-neutral and only emits trace logs.

View File

@@ -0,0 +1,381 @@
# Daemon Startup Sequence Analysis - 2026-05-27
Source log: `log.txt` at repository root.
Scope: current sliced startup log, starting at daemon worker startup and ending after workspace registry reconciliation and the first OpenCode heartbeat.
This report is descriptive only. It does not propose optimizations.
## Executive Summary
The daemon becomes ready quickly, then does a heavy post-listen startup pass driven by reconnecting clients and workspace/app hydration.
- Worker start: `16:03:46.678`, line 1.
- Server listening: `16:03:48.285`, line 47, elapsed `602ms`.
- First client hello: `16:03:50.285`, line 66.
- Workspace registries reconciled: `16:04:33.666`, line 1777, elapsed `45983ms`.
The startup shape is therefore:
- Daemon listen readiness: about `0.6s`.
- Client reconnect plus workspace/app/provider hydration: about `45s`.
- No git commands after workspace registry reconciliation in this slice.
## Method
I parsed structured trace lines from `log.txt`, especially:
- `Git command closed`
- `agent.session.inbound`
- `agent.session.outbound`
- `ws_slow_request`
- provider snapshot warnings
- provider resume events
Important limitation: git command logs do not carry a websocket request id, so per-request attribution is inferred from timing and server code paths. Per-workspace git counts, command shapes, durations, and failures are exact for this log.
Relevant code paths checked:
- `packages/server/src/server/session.ts`
- `fetch_workspaces_request` calls `syncWorkspaceGitObservers(payload.entries)`.
- `checkout_status_request` calls `workspaceGitService.getSnapshot(resolvedCwd)`.
- `checkout_pr_status_request` calls `workspaceGitService.getSnapshot(cwd)`.
- `packages/server/src/server/workspace-git-service.ts`
- checkout snapshot/root resolution uses `git rev-parse --show-toplevel`.
- snapshot refresh collects dirty state, upstream/ahead/behind, ref existence, and base divergence.
- `packages/app/src/contexts/session-context.tsx`
- initial workspace hydration calls `client.fetchWorkspaces({ sort: activity_at desc, subscribe, page limit 200 })`.
- `packages/app/src/hooks/use-sidebar-workspaces-list.ts`
- sidebar workspace refresh also calls `client.fetchWorkspaces({ sort: activity_at desc, page limit 200 })`.
## Startup Timeline
| time | line | event |
| -------------- | ---: | ------------------------------------------------------------------ |
| `16:03:46.678` | 1 | `DaemonRunner` starts daemon worker |
| `16:03:47.683` | 4 | worker spawned |
| `16:03:47.684` | 6 | daemon keypair loaded |
| `16:03:48.281` | 44 | bootstrap complete, ready to listen |
| `16:03:48.285` | 47 | server listening on `0.0.0.0:6767` |
| `16:03:50.274` | 60 | first websocket awaiting hello |
| `16:03:50.285` | 66 | first client connected via hello |
| `16:04:22.466` | 987 | OpenCode provider snapshot timeout for `/Users/moboudra/dev/paseo` |
| `16:04:22.482` | 1002 | OpenCode provider snapshot timeout for `/Users/moboudra` |
| `16:04:24.183` | 1201 | OpenCode provider subscribe starts |
| `16:04:24.183` | 1202 | OpenCode provider subscribe ready |
| `16:04:24.306` | 1214 | OpenCode server connected event |
| `16:04:25.933` | 1321 | OpenCode agent resumed from persistence |
| `16:04:33.666` | 1777 | workspace registries reconciled |
| `16:04:34.197` | 1783 | OpenCode heartbeat |
| `16:04:44.200` | 1789 | OpenCode heartbeat |
## Git Command Totals
Total git commands in the sliced startup: `444`.
| phase | commands | failures | summed process time |
| ------------------------------- | -------: | -------: | ------------------: |
| daemon bootstrap before listen | 13 | 4 | 445ms |
| post-listen before first client | 1 | 0 | 2020ms |
| client reconnect + reconcile | 430 | 71 | 120813ms |
| after reconcile | 0 | 0 | 0ms |
| total | 444 | 75 | 123278ms |
Summed process time is not wall-clock time. Many commands overlap.
## Git Command Categories
| category | commands | failures | summed process time | max duration |
| ---------------------------------------------------- | -------: | -------: | ------------------: | -----------: |
| ahead/behind: `rev-list --count ...` | 115 | 30 | 35815ms | 1557ms |
| refs: `show-ref --verify --quiet ...` | 86 | 2 | 14680ms | 1303ms |
| upstream config: `config --get branch.*` | 85 | 13 | 26437ms | 1624ms |
| root detection: `rev-parse --show-toplevel` | 80 | 30 | 24164ms | 1426ms |
| dirty status: `status --porcelain` | 50 | 0 | 12670ms | 2020ms |
| base divergence: `rev-list --left-right --count ...` | 28 | 0 | 9512ms | 1085ms |
What those categories mean in the app:
- Root detection: determine whether a cwd is inside a git repo and find its checkout root.
- Dirty status: show dirty/clean workspace state.
- Upstream config and ahead/behind: show branch tracking and sync state.
- Ref existence and base divergence: compare checkout branch against candidate base refs for checkout/PR status.
## Per-Workspace Git Work
Columns:
- `phase`: `pre/warm/reconnect/after`
- `cats`: `root/dirty/upstream/ahead/refs/base/other`
- `total_ms`: summed process time for that workspace
| workspace | cmds | fail | phase | cats | total_ms | max_ms | window | failing command shapes |
| ----------------------------------------------------------------------- | ---: | ---: | ---------- | ----------------- | -------: | -----: | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `~/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8255 | 1460 | `16:03:52.908-16:04:24.019` | `3x config --get branch.fix-compaction-cancel-loading.remote`; `3x rev-list --count fix-compaction-cancel-loading..origin/fix-compaction-cancel-loading`; `3x rev-list --count origin/fix-compaction-cancel-loading..fix-compaction-cancel-loading` |
| `~/.paseo/worktrees/1luy0po7/hopeful-eel` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8468 | 1544 | `16:03:53.245-16:04:27.334` | `3x config --get branch.feat/markdown-annotations.remote`; `3x rev-list --count feat/markdown-annotations..origin/feat/markdown-annotations`; `3x rev-list --count origin/feat/markdown-annotations..feat/markdown-annotations` |
| `~/.paseo/worktrees/1luy0po7/merry-ladybug` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 7154 | 1099 | `16:03:53.696-16:04:29.644` | `3x config --get branch.feat/mcp-configuration.remote`; `3x rev-list --count feat/mcp-configuration..origin/feat/mcp-configuration`; `3x rev-list --count origin/feat/mcp-configuration..feat/mcp-configuration` |
| `~/dev/paseo` | 30 | 0 | `2/0/28/0` | `5/5/10/10/0/0/0` | 7457 | 1624 | `16:03:48.171-16:04:27.284` | |
| `~/.paseo/worktrees/0vpo9h4b/dazzling-duck` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7617 | 1269 | `16:03:51.918-16:04:23.971` | |
| `~/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7428 | 1426 | `16:03:52.445-16:04:23.991` | |
| `~/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7764 | 1091 | `16:03:52.681-16:04:23.971` | |
| `~/dev/emdash` | 22 | 6 | `0/0/22/0` | `2/2/2/6/8/2/0` | 3031 | 453 | `16:04:27.351-16:04:29.583` | `2x config --get branch.heads/main.remote`; `2x rev-list --count heads/main..origin/heads/main`; `2x rev-list --count origin/heads/main..heads/main` |
| `~/dev/opencode` | 22 | 4 | `0/0/22/0` | `2/2/2/6/8/2/0` | 2279 | 313 | `16:04:24.058-16:04:24.970` | `2x rev-list --count ecosystem-paseo..origin/ecosystem-paseo`; `2x rev-list --count origin/ecosystem-paseo..ecosystem-paseo` |
| `~/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | 18 | 0 | `0/0/18/0` | `3/3/6/6/0/0/0` | 7467 | 1242 | `16:03:53.781-16:04:23.971` | |
| `~/dev/blankpage/editor` | 18 | 0 | `2/0/16/0` | `3/3/6/6/0/0/0` | 2418 | 520 | `16:03:48.174-16:04:26.411` | |
| `~/dev/konbert/web` | 18 | 0 | `1/1/16/0` | `3/3/6/6/0/0/0` | 7324 | 2020 | `16:03:48.190-16:04:23.685` | |
| `~/dev/openchamber` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1554 | 336 | `16:04:27.399-16:04:29.616` | |
| `~/dev/superset` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1019 | 215 | `16:04:27.341-16:04:29.617` | |
| `~/dev/t3code` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 2761 | 588 | `16:04:27.356-16:04:29.603` | |
| `~/.paseo/worktrees/0vpo9h4b/breezy-toad` | 11 | 5 | `0/0/11/0` | `1/1/1/3/4/1/0` | 6465 | 1290 | `16:03:51.307-16:04:18.112` | `1x config --get branch.fix/user-delete-dark-mode.remote`; `1x rev-list --count fix/user-delete-dark-mode..origin/fix/user-delete-dark-mode`; `1x rev-list --count origin/fix/user-delete-dark-mode..fix/user-delete-dark-mode`; `2x show-ref --verify --quiet refs/remotes/origin/my-branch` |
| `~/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | 11 | 3 | `0/0/11/0` | `1/1/1/3/4/1/0` | 4303 | 757 | `16:03:52.539-16:04:19.011` | `1x config --get branch.fix-archive-worktree-session-history.remote`; `1x rev-list --count fix-archive-worktree-session-history..origin/fix-archive-worktree-session-history`; `1x rev-list --count origin/fix-archive-worktree-session-history..fix-archive-worktree-session-history` |
| `~/.paseo/worktrees/0vpo9h4b/codex-github-mention-implement-db-garbage` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 4986 | 1005 | `16:03:51.261-16:04:16.878` | |
| `~/.paseo/worktrees/1luy0po7/feat-find-in-pane` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5273 | 1130 | `16:03:52.391-16:04:17.682` | |
| `~/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5176 | 839 | `16:03:52.110-16:04:18.254` | |
| `~/.paseo/worktrees/steering-policy-refactor-detached` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5993 | 1243 | `16:03:53.984-16:04:17.673` | |
| `~/dev/faro/main` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 4964 | 1603 | `16:03:48.168-16:04:03.748` | |
| `~/dev/paseo-cloud` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 2123 | 1154 | `16:03:48.159-16:03:56.377` | |
| `~/dev/assistant` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 85 | 29 | `16:03:48.165-16:04:24.048` | `3x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/review` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 224 | 105 | `16:03:48.197-16:04:26.560` | `3x rev-parse --show-toplevel` |
| `~/dev/research/orchestrator-worker` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 285 | 144 | `16:03:48.194-16:04:26.575` | `3x rev-parse --show-toplevel` |
| `/tmp` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 113 | 77 | `16:04:27.388-16:04:27.471` | `2x rev-parse --show-toplevel` |
| `~/dev` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 86 | 58 | `16:04:27.384-16:04:27.457` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/01-claude-opus` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 216 | 185 | `16:04:26.525-16:04:26.543` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/02-codex-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 98 | 68 | `16:04:26.353-16:04:26.554` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/03-opencode-zai-glm51` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 209 | 158 | `16:04:26.512-16:04:26.576` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/04-opencode-zen-minimax27` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 148 | 101 | `16:04:26.431-16:04:26.549` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/05-opencode-zen-kimi26` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 231 | 172 | `16:04:26.517-16:04:26.577` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/06-opencode-or-deepseek4pro` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 93 | 73 | `16:04:27.365-16:04:27.380` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/07-opencode-zen-gemini35flash` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 78 | 66 | `16:04:27.363-16:04:27.375` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/08-opencode-zen-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 120 | 65 | `16:04:27.359-16:04:27.430` | `2x rev-parse --show-toplevel` |
| `~/dev/assistant/game` | 1 | 1 | `1/0/0/0` | `1/0/0/0/0/0/0` | 13 | 13 | `16:03:48.155-16:03:48.155` | `1x rev-parse --show-toplevel` |
## Git Failure Shape
There were 75 nonzero git exits.
Most failures were not timeouts. They were expected probe failures:
- Non-repo checks: `rev-parse --show-toplevel` fails for paths that are not git repositories.
- Missing upstream config: `config --get branch.<branch>.remote` fails for branches without configured upstream.
- Missing remote branch graph: `rev-list --count <branch>..origin/<branch>` fails when the remote branch/ref does not exist.
- Missing ref checks: `show-ref --verify --quiet refs/remotes/origin/my-branch` fails when a candidate ref does not exist.
The `~/dev/opencode` git failures are branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`, not OpenCode provider startup failures.
## Inbound Client Work
Inbound session messages during the startup window:
| request | count |
| --------------------------------- | ----: |
| `client_heartbeat` | 19 |
| `checkout_pr_status_request` | 18 |
| `fetch_agents_request` | 11 |
| `fetch_workspaces_request` | 9 |
| `get_providers_snapshot_request` | 9 |
| `project_icon_request` | 9 |
| `fetch_agent_timeline_request` | 7 |
| `clear_agent_attention` | 6 |
| `list_terminals_request` | 5 |
| `subscribe_terminals_request` | 5 |
| `list_available_editors_request` | 2 |
| `subscribe_checkout_diff_request` | 2 |
| `checkout_status_request` | 1 |
| `fetch_agent_request` | 1 |
| `file_explorer_request` | 1 |
| `read_project_config_request` | 1 |
| `workspace_setup_status_request` | 1 |
Inbound by client:
| client | count | top work |
| ----------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Electron `cid_d555...`, origin `http://localhost:8082` | 68 | `checkout_pr_status_request:18`, `project_icon_request:9`, `clear_agent_attention:6`, `fetch_agent_timeline_request:4`, `fetch_workspaces_request:3`, `fetch_agents_request:3`, `get_providers_snapshot_request:3` |
| HeadlessChrome `cid_d39...`, origin `http://localhost:8081` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| local web `cid_a2b...`, origin `http://localhost:6767` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| Android `cid_24c...`, origin `http://10.0.2.2:6767` | 11 | `client_heartbeat:2`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| `cid_70d...`, host `0.0.0.0:6767` | 2 | `fetch_agents_request:2` |
## Outbound Client Work
Outbound session messages during the startup window:
| message | count |
| ---------------------------------- | ----: |
| `providers_snapshot_update` | 129 |
| `workspace_update` | 81 |
| `checkout_status_update` | 76 |
| `agent_update` | 47 |
| `checkout_pr_status_response` | 18 |
| `fetch_agents_response` | 11 |
| `fetch_workspaces_response` | 9 |
| `get_providers_snapshot_response` | 9 |
| `project_icon_response` | 9 |
| `fetch_agent_timeline_response` | 7 |
| `list_terminals_response` | 5 |
| `terminals_changed` | 5 |
| `list_available_editors_response` | 2 |
| `subscribe_checkout_diff_response` | 2 |
| `checkout_status_response` | 1 |
| `fetch_agent_response` | 1 |
| `file_explorer_response` | 1 |
| `read_project_config_response` | 1 |
| `workspace_setup_status_response` | 1 |
Provider snapshot updates were large and repeated:
- Around lines 982-986: five `providers_snapshot_update` messages, each `215932` bytes.
- Around lines 997-1001: five `providers_snapshot_update` messages, each `215898` bytes.
- Around lines 1250-1254: five `providers_snapshot_update` messages, each `414735` bytes.
## Slow Requests
Slow requests logged during startup:
| time | request | duration | client | line |
| -------------- | --------------------------------: | -------: | --------------------- | ---: |
| `16:04:29.702` | `fetch_agent_timeline_request` | 39372ms | HeadlessChrome | 1767 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 39212ms | Electron | 1768 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 38914ms | local web | 1769 |
| `16:04:33.665` | `checkout_pr_status_request` | 20181ms | Electron | 1776 |
| `16:04:08.109` | `subscribe_checkout_diff_request` | 17618ms | Electron | 565 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 16216ms | Electron | 1770 |
| `16:04:06.624` | `fetch_agent_timeline_request` | 16134ms | Electron | 524 |
| `16:04:29.396` | `checkout_pr_status_request` | 15911ms | Electron | 1671 |
| `16:04:29.256` | `checkout_pr_status_request` | 15772ms | Electron | 1651 |
| `16:04:29.149` | `checkout_pr_status_request` | 15665ms | Electron | 1638 |
| `16:04:29.054` | `checkout_pr_status_request` | 15569ms | Electron | 1628 |
| `16:04:28.932` | `checkout_pr_status_request` | 15448ms | Electron | 1611 |
| `16:04:28.809` | `checkout_pr_status_request` | 15324ms | Electron | 1601 |
| `16:04:28.672` | `checkout_pr_status_request` | 15188ms | Electron | 1582 |
| `16:04:28.555` | `checkout_pr_status_request` | 15071ms | Electron | 1567 |
| `16:04:28.421` | `checkout_pr_status_request` | 14936ms | Electron | 1556 |
| `16:04:28.323` | `checkout_pr_status_request` | 14839ms | Electron | 1549 |
| `16:04:28.324` | `checkout_status_request` | 14839ms | Electron | 1550 |
| `16:04:28.189` | `checkout_pr_status_request` | 14705ms | Electron | 1536 |
| `16:04:29.634` | `fetch_agents_request` | 14590ms | `0.0.0.0:6767` client | 1759 |
| `16:04:28.006` | `checkout_pr_status_request` | 14522ms | Electron | 1526 |
| `16:04:27.628` | `checkout_pr_status_request` | 14143ms | Electron | 1496 |
| `16:04:27.061` | `checkout_pr_status_request` | 13576ms | Electron | 1405 |
| `16:04:29.645` | `fetch_agents_request` | 13384ms | `0.0.0.0:6767` client | 1762 |
| `16:04:02.812` | `fetch_agent_timeline_request` | 12321ms | Electron | 440 |
| `16:04:25.740` | `checkout_pr_status_request` | 12256ms | Electron | 1309 |
| `16:04:25.352` | `checkout_pr_status_request` | 11867ms | Electron | 1296 |
| `16:04:04.217` | `fetch_agent_timeline_request` | 11751ms | Android | 462 |
| `16:04:25.155` | `checkout_pr_status_request` | 11671ms | Electron | 1284 |
| `16:04:23.196` | `fetch_agent_request` | 9711ms | Electron | 1070 |
| `16:04:17.563` | `project_icon_request` | 4079ms | Electron | 877 |
| `16:03:53.022` | `list_available_editors_request` | 2533ms | Electron | 254 |
| `16:04:15.703` | `project_icon_request` | 2218ms | Electron | 824 |
| `16:04:15.696` | `project_icon_request` | 2211ms | Electron | 822 |
| `16:04:15.694` | `project_icon_request` | 2209ms | Electron | 820 |
| `16:04:15.103` | `file_explorer_request` | 1618ms | Electron | 806 |
| `16:04:14.107` | `list_terminals_request` | 621ms | Electron | 764 |
| `16:03:50.945` | `list_terminals_request` | 614ms | HeadlessChrome | 156 |
The checkout PR requests are especially clustered: 18 Electron `checkout_pr_status_request` messages arrive together at `16:04:13.484`, lines 699-716. Their slow-request completions drain over the next ~20s, with `inflightRequests` dropping from 20 to 0.
## Provider Findings
### OpenCode
OpenCode provider snapshot refresh had two timeouts:
| time | line | cwd | error |
| -------------- | ---: | --------------------------- | --------------------------------------------- |
| `16:04:22.466` | 987 | `/Users/moboudra/dev/paseo` | `Timed out refreshing OpenCode after 30000ms` |
| `16:04:22.482` | 1002 | `/Users/moboudra` | `Timed out refreshing OpenCode after 30000ms` |
These are provider snapshot failures, not OpenCode agent resume failures.
The persisted OpenCode agent did resume:
| time | line | event |
| -------------- | ---: | ----------------------------------------------------- |
| `16:04:24.183` | 1201 | `provider.opencode.subscribe.start` |
| `16:04:24.183` | 1202 | `provider.opencode.subscribe.ready` |
| `16:04:24.306` | 1214 | raw event `server.connected` |
| `16:04:25.933` | 1321 | `Agent resumed from persistence`, provider `opencode` |
| `16:04:34.197` | 1783 | raw event `server.heartbeat` |
| `16:04:44.200` | 1789 | raw event `server.heartbeat` |
There are no `provider.opencode.subscribe.error` or OpenCode agent fatal errors in this slice.
OpenCode-related git:
- `~/dev/opencode` had 22 git commands.
- Four failed.
- The failed commands were branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`.
- Those failures are git state/probe failures, not OpenCode provider process failures.
### Codex
Codex provider startup observations:
- `provider.codex.spawn` appears multiple times for provider snapshot/config discovery.
- A persisted Codex agent resumes successfully at `16:04:06.357`, line 518.
- Debug logs show failed reads of Codex saved config defaults, but these are debug-level and do not become provider startup warnings/errors in this slice.
- There are unhandled Codex trace event types such as remote-control/status and thread/goal status, but no Codex timeout or fatal provider startup failure in this slice.
### Claude
Claude agents resume successfully:
| time | line | client | agent |
| -------------- | ---: | -------- | -------------------------------------- |
| `16:04:02.540` | 434 | Electron | `f884552a-1383-4dba-8583-7ae0b6a62353` |
| `16:04:03.772` | 456 | Android | `0c89a057-05f2-4e23-9895-84c8e1952310` |
## What Work The App Asked For
The startup work visible in the app/server protocol is:
- Workspace list/sidebar hydration:
- `fetch_workspaces_request`, 9 total.
- This asks for the workspace list sorted by `activity_at desc`, usually page limit 200.
- On the server this triggers workspace git observer sync and workspace update flushing.
- Agent list and agent detail hydration:
- `fetch_agents_request`, 11 total.
- `fetch_agent_request`, 1 total.
- `fetch_agent_timeline_request`, 7 total.
- Timeline requests are among the slowest requests in this slice.
- Checkout/PR status UI:
- `checkout_pr_status_request`, 18 total, all Electron.
- `checkout_status_request`, 1 total.
- `subscribe_checkout_diff_request`, 2 total.
- These correspond to git snapshot consumers and are clustered during Electron reconnect.
- Provider/model/mode UI:
- `get_providers_snapshot_request`, 9 total.
- `providers_snapshot_update`, 129 outbound updates.
- OpenCode provider snapshot refresh times out twice during this flow.
- Workspace chrome:
- `project_icon_request`, 9 total.
- `file_explorer_request`, 1 total.
- Terminal panel:
- `list_terminals_request`, 5 total.
- `subscribe_terminals_request`, 5 total.
- `terminals_changed`, 5 outbound updates.
- Attention state:
- `clear_agent_attention`, 6 total.
- Some failures appear while clearing attention for persisted agents, but these are not provider startup failures.
## Concrete Waste-Looking Work, Without Optimizing Yet
The log shows repeated work in these exact forms:
- 444 git commands total, but only 14 complete before the first client hello. The rest are post-listen startup/client hydration work.
- Several workspaces get repeated full checkout snapshot patterns:
- three 33-command worktrees each get `3` root checks, `3` dirty checks, `3` upstream config probes, `9` ahead/behind probes, `12` ref checks, and `3` base divergence checks.
- three 27-command worktrees each get `3` root checks, `3` dirty checks, `6` upstream config probes, `6` ahead/behind probes, `6` ref checks, and `3` base divergence checks.
- `~/dev/paseo` gets `5` root checks, `5` dirty checks, `10` upstream config probes, and `10` ahead/behind probes.
- Electron sends 18 `checkout_pr_status_request` messages at the same timestamp, then they drain slowly over ~20s.
- Provider snapshot updates are broadcast very frequently: 129 outbound `providers_snapshot_update` messages, including large repeated payloads around 216KB and 415KB.
- OpenCode snapshot refresh times out twice after 30s, but the actual OpenCode agent connection/resume succeeds.
Again, this section names repeated work observed in the startup. It does not claim which repetition should be removed.

193
docs/floating-panels.md Normal file
View File

@@ -0,0 +1,193 @@
# Floating Panels
Anchored popovers — tooltips, hover cards, dropdowns, autocompletes — that visually
float above an anchor element on iOS, Android, and web. This doc captures the
non-obvious traps. It is **not** a tutorial; it assumes you have seen the
canonical files and are trying to add or change one.
## Canonical files
| File | Use case |
| ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
input focused while its scrollable list lives in a Portal. There is no shared
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
until then prefer copying the closest file and trimming.
## Gotcha 1 — Android touch hit-test by parent bounds
On Android, a child View whose bounds fall outside its parent's bounds renders
correctly (with `overflow: visible`, the default) but **does not receive touch
events**. `ViewGroup.dispatchTouchEvent` filters touches by the parent's hit
rect first, then iterates children. A touch in the overflowing region never
reaches the parent, let alone the child. iOS and web do not share this rule —
iOS hit-test descends into overflowing children, web uses standard CSS pointer
events. This is the bug that put autocomplete on this path: the popover was
positioned `bottom: 100%` of its parent and worked on iOS/web for months;
Android touches sailed straight through to the chat scroll view behind it.
Two escape hatches in the codebase:
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
hit-testing starts fresh in that window. Side effect: a Modal opening on
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is
full-screen. This is the right default when you must keep IME attachment.
Choose the host by layer: app-global overlays use the root host; content
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
cover them.
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
A Portal escapes Android's hit-test, but it also escapes two things you were
quietly relying on:
- **Lifecycle.** The portal'd subtree mounts at the app root, not inside your
component's natural ancestor chain. When the user navigates away, your
component may stay mounted (offscreen, in a tab) — the popover stays with it.
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`.
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move
together because they share the SharedValue. A portal'd popover is outside
the composer tree — it does not get that transform unless you apply it
yourself.
- **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Content overlays that must sit below sidebars should
use the current `FloatingPanelPortalHost`.
- **Coordinate systems.** `measureInWindow` gives window coordinates. A Portal
renders inside its host, not necessarily at window origin. Position anchored
content relative to the host: `anchorRect - hostRect`. This is what
`measureFloatingPanelPortalHost()` is for.
The fix for transforms is Gotcha 3.
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
`measureInWindow` returns the view's _current_ screen position. In theory that
includes Reanimated-applied transforms (Reanimated updates native view
properties, and Android's `getLocationInWindow` reads transformed coords). In
practice it's racy — the measurement may snapshot mid-animation, and on Android
with Reanimated worklets the result is not always stable.
If the panel cannot stay inside the transformed ancestor, do not try to track
the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same SharedValue the composer uses**:
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
to the popover wrapper.
When `shift` equals `openShift`, the translate is 0 and the popover sits at
the measured position. When the keyboard moves afterward, the delta translates
the popover by exactly the amount the composer translates. They move in
lockstep, no re-measurement needed.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.
## Gotcha 4 — Host-relative positioning before platform offsets
The generic anchored-overlay rule is:
1. Measure the anchor with `measureInWindow`.
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
3. Position with anchor coordinates relative to the host:
```ts
left = anchorRect.x - hostRect.x;
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
```
Do this before adding any platform offset. If anchor and host are both measured
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
Only add a status-bar offset when the render surface is not measured in the same
coordinate system. See `tooltip.tsx` for that separate case.
## Gotcha 5 — The two-measurement flash
If your popover needs `top` (or `left`) computed from both:
- the anchor's screen position (`anchorRect` from `measureInWindow`), **and**
- the popover's own size (`contentSize` from `onLayout`),
then a naïve implementation will flash through three positions on every open:
1. **Frame 1** — render with `top: -9999` (or any placeholder) while waiting
for either measurement. Wrapper has no `width`, so the inner content lays
out at its natural (often narrow) intrinsic width.
2. **Frame 2**`anchorRect` lands. Wrapper now has `width: anchorRect.width`.
But the stale `onLayout` from frame 1 has already set `contentSize` to the
narrow-width dimensions. `top = anchorRect.y - wrongHeight - gap` — visible
at the wrong spot.
3. **Frame 3** — real `onLayout` fires with the correct width. `contentSize`
updates. Position snaps to the right place.
The visible jump in frame 2 is the flash. Two pieces solve it, and you need
both:
- **Do not mount the floating content until `anchorRect` is set.** Return
`null` until then. This prevents the bad-width onLayout from happening at
all.
- **Once `anchorRect` is set but `contentSize` isn't, render the wrapper with
the final width but `opacity: 0`.** The first visible paint is at the
correct position. This is the combobox pattern —
`shouldHideDesktopContent` at `combobox.tsx:481, 876`. **Do not** use
`top: -9999` as the placeholder; the layout work still happens at -9999 and
any subsequent state-flash is visible when you flip back.
The "render invisible to measure, then reveal" pattern is the canonical
solution to chicken-and-egg positioning in this codebase. Reach for it before
anything fancier.
## Gotcha 6 — Bottom sheet refs are not lifecycle truth
`@gorhom/bottom-sheet` modals churn their imperative ref while presenting and
dismissing. Do not treat `ref != null` as permission to call `present()`, and do
not treat `ref == null` as the sheet being closed. The user-visible lifecycle is
the desired `visible` prop plus the sheet callbacks (`onChange(-1)`,
`onDismiss`).
If a user closes a sheet with the backdrop or a pan gesture, the sheet may detach
and reattach before React state has acknowledged `visible=false`. Re-presenting
on that attach races Gorhom's dismiss path and leaves the modal unable to reopen.
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
ignore ref churn while dismissing.
## Recipe for a new anchored panel
Before you write a new one, ask:
1. **Can the underlying input lose its keyboard?** If yes, use Modal (simpler).
If no, use Portal.
2. **Does the panel need to dismiss on screen change?** Almost always yes —
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
raw window coordinates as local Portal coordinates.
4. **Does the panel sit above something that moves with the keyboard?** If
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
If no, you can probably skip the transform entirely.
5. **Will the panel's content height vary?** If yes, you need both
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
null until anchor, then opacity-0 until contentSize). If no — content has
a known fixed max height — you might be able to use bottom-anchored
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
`contentSize` round-trip entirely. **But only if the height is genuinely
bounded**. Verify before you commit.
Then copy the closest canonical file and trim.

View File

@@ -3,27 +3,35 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/server/src/shared/messages.ts:1936`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2113`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/server/src/shared/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
## Inconsistencies (documented, not papered over)
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).

View File

@@ -249,6 +249,23 @@ const { theme } = useUnistyles();
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## Native Chat Stream Layout
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
- chronological stream order
- strategy-ordered array order
- native inverted cell visual order
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
Platform-specific stream edges belong on `StreamStrategy`:
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
## iOS Simulator
```bash

View File

@@ -16,7 +16,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1
```
## Baseline
@@ -44,6 +44,6 @@ One live reasoning-dedup matrix run returned no reasoning content; an immediate
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1`

View File

@@ -71,7 +71,7 @@ Anyone who builds software:
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP

View File

@@ -14,7 +14,23 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
@@ -22,11 +38,15 @@ Draft metadata lookups should avoid creating provider sessions when the upstream
## Provider Snapshot Refresh Contract
The daemon keeps one global provider snapshot, keyed to the home directory, for settings, selectors, and old model/mode list requests. Snapshot reads may probe providers only while the snapshot is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until the user forces a refresh from settings/provider management.
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery; warm reads and registry replacement must not; explicit full or targeted refreshes must.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
---

View File

@@ -0,0 +1,149 @@
# Session God-File Decomposition Plan
`packages/server/src/server/session.ts` — 9116 lines, one `Session` class (declared line 724), 128 handlers, ~60 instance fields, 7 entangled domains. Goal: a **strictly behavior-preserving, incremental** decomposition into per-domain controllers, mirroring the existing `TerminalSessionController`.
## Chosen strategy: controller-context (per-domain option-bag controllers)
Each domain becomes a controller class in its own file with the **exact** contract the repo already proved twice (`TerminalSessionController` at `packages/server/src/terminal/terminal-session-controller.ts`, and `CreateAgentLifecycleDispatch`):
- An **options-bag constructor** injecting only what that domain reads.
- An **owned-type `ReadonlySet`** of message types.
- A **NON-async `dispatch(msg): Promise<void> | undefined`** that checks the owned-type set FIRST and returns `undefined` synchronously on a miss (verified at terminal-session-controller.ts:140-143).
- `start()` wired from `subscribeToOptionalManagers`, `dispose()` called by the shell's ordered `cleanup()`.
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
## Slice ordering (least-coupled first)
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
| # | Slice | Effort | Risk |
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
| 2 | ProviderCatalogController | M | medium |
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
| 4 | GitCheckoutController | L | medium |
| 5 | WorkspaceController | XL | high |
| 6 | Voice prereqs: emit() purity + abortController ownership | M | high |
| 7 | VoiceSessionController | XL | high |
| 8a | Agent-lifecycle config setters | M | medium |
| 8b | AgentLifecycleController | XL | high |
---
## Slice 0 — Test net + disjointness tripwire (prerequisite)
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
Write RED-then-GREEN against the **current in-place** Session:
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
- one `schedule/*` and one `loop/*` round-trip.
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
- a handler that **rejects async** emits the SAME pair.
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
---
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError`**kept separate, not merged**), `toScheduleSummary``packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
---
## Slice 2 — ProviderCatalogController
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead``packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `providerSnapshotManager` (**shared by reference** — stays a daemon singleton read by checkout/lifecycle/workspace), `isProviderVisibleToClient` (predicate closing over `this`, reads `appVersion` live), `downgradeModeIconsForClient`, `downgradeEntryModesForClient`, agent-control reads `{ listProviderAvailability, listDraftFeatures }`.
**Behavior note:** COMPAT correctness — PUSH and PULL paths MUST call the SAME injected visibility/downgrade closures, reading `appVersion` LIVE (mutated post-construction via `updateAppVersion`). Keep `COMPAT(providersSnapshot)` and `COMPAT(customModeIcons)` comments verbatim. Do NOT pull `resolveStructuredGenerationProviders`/`getFocusedAgentSelectionForCwd` in. **Tests:** `session.dispatch-seam.test.ts`, `daemon-e2e/models.e2e.test.ts`, `session.test.ts`.
---
## Slice 3 — Split the shared observer seams (prerequisite, no controller)
In-place refactor on the shell, two named fan-outs:
1. **workspace-git observer** (4910-4917): make `emitCheckoutStatusUpdate` and `onBranchChanged` injectable callbacks; keep `workspaceGitWatchTargets`/`workspaceGitSubscriptions` shared by reference.
2. **agentManager.subscribe callback** (~1298): refactor into `{ onAgentUpdate, shouldAutoAllowVoicePermission(event), onStreamEvent }`.
**Behavior note:** the single hardest seam, split exactly once before the two domains that co-own it. The observer fires BOTH workspace (`handleWorkspaceGitBranchSnapshot`, `emitWorkspaceUpdateForCwd`) and checkout (`emitCheckoutStatusUpdate`) effects; the agent-subscribe callback is invoked by agent EVENTS (not the `??` chain) and does lifecycle + voice work. Add a test asserting BOTH a `workspace_update` and a `checkout_status_update` fire from one simulated git snapshot change, and a voice-permission test for the auto-allow path. **Tests:** `session.workspace-git-watch.test.ts`, `session.workspaces.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 4 — GitCheckoutController
**Move:** ~22 `checkout_*`/`stash_*`/PR/github handlers + `handleSubscribeCheckoutDiffRequest`/`handleUnsubscribeCheckoutDiffRequest` + `emitCheckoutStatusUpdate` + `checkoutDiffSubscriptions``packages/server/src/server/checkout/git-checkout-controller.ts`. Collapse `dispatchCheckoutMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `checkoutDiffManager` (move in + dispose teardown), `github` (shared), `workspaceGitService` (**shared spine**), `workspaceGitWatchTargets`/`workspaceGitSubscriptions` (**shared**), `providerSnapshotManager.listRegisteredProviderIds`. `emitCheckoutStatusUpdate` is now owned here and injected back into the workspace observer seam from Slice 3.
**Behavior note:** safe now that Slice 3 split the observer. `checkoutDiffSubscriptions` teardown moves to `dispose()`, called by `cleanup()` at its current ordinal (8530). **Tests:** `session.dispatch-seam.test.ts`, `checkout-diff-manager.test.ts`, `daemon-e2e/checkout-diff-subscription.e2e.test.ts`, `session.test.ts`.
---
## Slice 5 — WorkspaceController (XL)
**Move:** all workspace handlers (incl. re-homed `handleProjectRenameRequest` and `start_workspace_script_request`) + ~25 private workspace helpers + the whole `workspaceUpdatesSubscription` state machine → `packages/server/src/server/workspace/workspace-controller.ts`.
**SessionContext surface:** `emit`, `sessionLogger`, `projectRegistry`/`workspaceRegistry`/`downloadTokenStore`/script stores/editor cache (**owned**), `workspaceGitService` + watch maps (**shared with checkout**), injected `emitCheckoutStatusUpdate`/`onBranchChanged`, `terminalManager`/`killTerminalsUnderPath`, an `agentUpdatesSubscription` write via a narrow `bufferAgentUpdate` command, `providerSnapshotManager.listRegisteredProviderIds`.
**Behavior note:** the workspaceUpdatesSubscription machine moves WHOLE. The eight already-public workspace methods stay a public surface re-exposed via the shell. Re-homes are atomic remove-from-old-dispatcher + add-to-new-owned-set. **Tests:** `session.workspaces.test.ts`, `session.workspace-git-watch.test.ts`, `session.workspace-resolution-invariants.test.ts`, `session.test.ts`.
---
## Slice 6 — Voice prerequisites (emit purity + abort ownership)
In-place, separately reviewable. Split the `audio_output` TTS-debug branch out of `emit()` (8421-8468, bypasses to `onMessage` at 8454) so `emit` is a pure trace+onMessage sink. Move `convertPCMToWavBuffer` (674-701) to `speech/audio.ts`. Decide abortController ownership.
**Behavior note:** TTS-debug split and abortController ownership are the SAME decision (`ttsDebugStreams.clear()` is tied to `createAbortController` reassignment at 8359). Keep `emit` (with the universal trace) on the shell and inject it everywhere — no trace-less emit. Do NOT inject the AbortController by value. Add: a TTS-debug persistence test (with the debug env flag) before the move, and a barge-in→cleanup regression test asserting the NEW run's signal is aborted. **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 7 — VoiceSessionController (XL)
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
**Behavior note:** depends on Slices 3 + 6. `cleanup()` stays the ordered orchestrator and calls `voiceController.dispose()` at the position the inlined voice teardown occupies today (8505-8525). **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-local-agent.e2e.test.ts`, `session.voice-mcp-config.test.ts`, `session.test.ts`.
---
## Slice 8a — Agent-lifecycle config setters
Parameterize the 4 setter envelopes `handleSetAgentMode/Model/Feature/Thinking` (4209-4390) into one helper; re-home `handleListCommandsRequest` (misfiled in `dispatchMiscMessage`). Add a handleMessage-driven **failure** test per setter (force the command to reject, assert both the `*_response{accepted:false}` AND the `activity_log` error frame in order) BEFORE collapsing. **Tests:** `session.test.ts`, `session.lifecycle-boundary.test.ts`.
## Slice 8b — AgentLifecycleController (XL, LAST)
**Move:** remaining lifecycle handlers + the `agentUpdatesSubscription` fan-out (`bufferOrEmitAgentUpdate`, `flushBootstrappedAgentUpdates`, `matchesAgentFilter`, `forwardAgentUpdate`) → `packages/server/src/server/agent/agent-lifecycle-controller.ts`. Collapse the three lifecycle dispatchers.
**SessionContext surface:** `emit`, `sessionLogger`, `agentManager`/`agentStorage` (**owned**), injected `forwardAgentUpdate``buildProjectPlacementForCwd` (backed by WorkspaceController), `agentUpdatesSubscription` accessor (owned; workspace writes via `bufferAgentUpdate`), `isProviderVisibleToClient`, `resolveCreateAgentWorkspace`, `supports`, `mcpBaseUrl`, `terminalController.killTerminalForClose`.
**Behavior note:** done LAST — the shared-projection hub. `handleCloseItemsRequest` splits its terminal-kill half from its agent-archive half. **Tests:** `session.test.ts`, `session.wait-for-finish.test.ts`, `session.create-agent-title.test.ts`, `session.lifecycle-boundary.test.ts`, `daemon-client.e2e.test.ts`.
---
## Cross-cutting invariants (every slice)
- **Always** run `npm run typecheck` and `npm run lint` after each slice; run `npm run build:server` before diagnosing cross-package type errors.
- Controller `dispatch` is **NON-async**, guarded by an owned-type `ReadonlySet` check returning `undefined` synchronously on miss. Never `async dispatch`.
- Controllers add **no** try/catch inside `dispatch` — error handling stays in `handleMessage`.
- `cleanup()` stays the single ordered teardown orchestrator on the shell.
- Move domain error emitters **verbatim**; treat any cross-domain emitter merge as a separate, test-guarded change.
- Per-slice typecheck/lint/format via `npm run` scripts; never re-run the full suite locally (run only the listed files with `--bail=1`).

View File

@@ -2,12 +2,38 @@
All workspaces share one version and release together.
## Two steps
A release has exactly two steps. The agent does the first, the user authorizes the second.
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
if stale package-runner pins are intentional, say so explicitly, otherwise run
`npm run acp:version-drift:update` and commit the updated catalog
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
**Go-ahead** (user says "go ahead"):
- commit the approved changelog
- run the release
Rules that apply to both steps:
- Last-minute changes always need approval. Every time.
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
## Two paths
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and publish npm only on the explicit `beta` dist-tag.
## Standard release (patch)
@@ -21,11 +47,11 @@ Before running any stable patch release command:
npm run release:patch
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering `Desktop Release`, `Android APK Release`, EAS `release-mobile.yml`, and `Release Notes Sync` workflows).
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
## Manual step-by-step
@@ -40,21 +66,23 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
## Beta flow
```bash
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
```
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
Use the beta path when you need to:
- smoke a build yourself before promoting it to everyone
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
@@ -147,6 +175,83 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
## Mobile builds (EAS)
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
### Watching mobile builds from the terminal
Use the EAS CLI from `packages/app/`:
```bash
cd packages/app
# Recent builds (newest first). Pipe to jq for status only.
npx eas build:list --limit 8 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Recent EAS workflow runs. This is the source of truth for submit/review jobs.
npx eas workflow:runs --json | jq '.[] | {status, workflowName, trigger, gitCommitHash, startedAt, finishedAt}'
# Filter by platform.
npx eas build:list --platform ios --limit 5 --non-interactive --json
npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Inspect the full release workflow, including submit_ios, submit_android,
# and submit_ios_for_review.
npx eas workflow:view <workflow-run-id> --json
# Read failed submit/review job logs.
npx eas workflow:logs <workflow-job-id> --all-steps --non-interactive
# Stream logs for a build.
npx eas build:view <build-id> --json | jq '.logFiles[]'
```
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW``IN_QUEUE``IN_PROGRESS``FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
For the `Release Mobile` EAS workflow, these jobs must pass:
- `build_ios` — iOS binary built
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `submit_android` — Android binary submitted to the Play Store
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
### Babysitting mobile after a release
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
Pattern:
```jsonc
// mcp__paseo__create_heartbeat arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"cron": "*/15 * * * *",
"maxRuns": 8, // covers ~2h of build + store-submission window
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
}
```
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
## Release notes on GitHub
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
@@ -199,6 +304,7 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- If `release:publish:beta` partially fails, re-run it — npm skips already-published versions and keeps prereleases off `latest` because every publish uses `--tag beta`
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
## Changelog format
@@ -207,30 +313,40 @@ Release notes depend on the changelog heading format. The heading **must** be st
```
## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` includes stable releases and the current beta line.
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
- Do not create duplicate entries for each beta on the same version line.
- `CHANGELOG.md` only lists stable releases. Betas are silent.
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
## Changelog ownership
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
- **The agent running the stable release writes the changelog entry.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
- Draft the entry from the stable-to-stable diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it.
## Changelog voice
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
| Wrong (implementation-facing) | Right (user-facing) |
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
| Added debouncing to the search input | Search results no longer lag behind typing |
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
@@ -241,9 +357,12 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
@@ -286,31 +405,32 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
Review the diff between the latest release tag and `HEAD`. Focus on:
> Review the diff between the latest release tag and HEAD. Focus on:
>
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
> 3. **Regressions** — anything that looks like it could break existing functionality.
>
> Diff: `git diff <latest-release-tag>..HEAD`
1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
3. **Regressions** — anything that looks like it could break existing functionality.
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
## Changelog scope
The changelog always covers **stable-to-HEAD**:
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
@@ -319,4 +439,9 @@ In other words, betas are checkpoints along the way; the changelog entry remains
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
- [ ] EAS `Release Mobile` workflow for the same tag is green
- [ ] EAS iOS `build_ios` completes for the same tag
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track

View File

@@ -11,7 +11,7 @@ The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- Operation: `set_auto_merge`
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
- Direction: `request` or `response`
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.

92
docs/service-proxy.md Normal file
View File

@@ -0,0 +1,92 @@
# Service Proxy
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
## How it works
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
The generated hostname is built from the script name, branch, and project:
```
<script>--<branch>--<project>.localhost
```
If the branch is `main` or `master`, the branch segment is omitted:
```
<script>--<project>.localhost
```
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
```
dev--feature-auth--miniweb.localhost
```
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Configuration
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
```json
{
"version": 1,
"daemon": {
"serviceProxy": {
"listen": "0.0.0.0:8080",
"publicBaseUrl": "https://paseoapps.my.domain.com"
}
}
}
```
| Field | Required | Description |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
## DNS and reverse proxy setup
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
1. Configure a wildcard DNS record:
```
*.paseoapps.my.domain.com → 10.1.1.1
```
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
Nginx example:
```nginx
server {
listen 443 ssl;
server_name *.paseoapps.my.domain.com;
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
}
}
```
## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
| Variable | Description |
| ------------------------------------- | ------------------------------------------------------------------------- |
| `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
| `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |

View File

@@ -102,16 +102,18 @@ When a test is labeled end-to-end, it calls the real service. No environment var
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
| Suffix | What it is | Where it runs |
| --------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/OpenCode) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
| Suffix | What it is | Where it runs |
| --------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E).
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
### Test setup
@@ -127,7 +129,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run Playwright E2E (`packages/app/e2e/*.spec.ts`) locally — defer to CI.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
## Agent authentication in tests
@@ -155,3 +157,12 @@ If code isn't testable, refactor it. Signs:
- Setup requires too much global state
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
## Two test categories, no others
Every test in this repo lives in exactly one of these shapes:
1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter.
2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport.
Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out.

44
docs/timeline-sync.md Normal file
View File

@@ -0,0 +1,44 @@
# Timeline sync
Agent chat delivery has two paths:
1. **Live stream**`agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history**`fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is:
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
## Presence is not delivery
Client heartbeat reports presence:
- device type
- app visibility
- focused agent
- last activity time
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
## Catch-up is paged but complete
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
## Resume behavior
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
When a client resumes without a cursor, it fetches the latest tail page.
## Relevant code
- Server live stream forwarding: `packages/server/src/server/session.ts`
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
- Session wiring: `packages/app/src/contexts/session-context.tsx`

View File

@@ -4,15 +4,17 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Forbidden
## STOP — `useUnistyles()` Is Banned
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
**Do not call `useUnistyles()`. Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to **every** Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with `theme` as the only changed input every cycle. It also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
Reviewers MUST reject PRs that introduce a new `useUnistyles()` call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.
Use these alternatives in order:
### 1. `StyleSheet.create((theme) => ...)` — default
@@ -46,31 +48,9 @@ const ThemedBlur = withUnistyles(BlurView);
(Mind the `> *` child-selector leak documented further down.)
### 4. Lift the read into a tiny leaf component
### 4. There is no "last resort"
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
### 5. (Last resort) `useUnistyles()`
Only acceptable when both of:
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
- (b) The component is small, leaf-level, and not on a hot render path.
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
### Hot-path forbidden list
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
- `AgentPanel` body (`packages/app/src/panels/agent-panel.tsx`)
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
There is no escape hatch. If none of (1)(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.
## How Updates Propagate
@@ -80,6 +60,36 @@ The important detail: the automatic native path tracks `props.style`. It does no
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
## Dynamic Pixel Styles On Web
Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.
Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.
## Inline Style Escape Hatch
When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`.
```tsx
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
const styles = StyleSheet.create({
thumb: {
position: "absolute",
},
});
<View style={[styles.thumb, inlineUnistylesStyle({ height, transform: [{ translateY }] })]} />;
```
This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.
Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
@@ -281,6 +291,21 @@ That brief transition is expected with the current storage model. It makes track
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
## Runtime Theme Patching For User Preferences
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
Gotchas:
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
## Debugging
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:

View File

@@ -4,6 +4,7 @@ pre-commit:
- name: format
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
exclude:
- "package-lock.json"
- "**/package-lock.json"
run: npm run format:check:files -- {staged_files}
- name: lint

View File

@@ -73,10 +73,10 @@ buildNpmPackage rec {
# Native deps (terminal emulation; libuv-linked on Linux)
npm rebuild node-pty
# Daemon workspaces (highlight + relay + server + cli)
npm run build:daemon
# Server workspaces (highlight + relay + protocol + client + server + cli)
npm run build:server
# App workspace deps not covered by build:daemon
# App workspace deps not covered by build:server
npm run build --workspace=@getpaseo/expo-two-way-audio
# Expo web export for the Electron renderer

View File

@@ -124,6 +124,17 @@ in
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
};
publicUseTls = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
description = ''
Whether the public (client-facing) relay endpoint uses TLS.
When `null` (default), the daemon falls back to `relay.useTls`.
Override when the internal path is plain `ws://` behind a
TLS-terminating reverse proxy.
'';
};
};
inheritUserEnvironment = lib.mkOption {
@@ -253,6 +264,8 @@ in
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.publicUseTls != null) {
PASEO_RELAY_PUBLIC_USE_TLS = if cfg.relay.publicUseTls then "true" else "false";
} // cfg.environment;
serviceConfig = {

View File

@@ -1 +1 @@
sha256-SRax+Frqf0OhDhfWICSlhOnHBGsoHiXQc6P+zo5VjN8=
sha256-MEG/ywKGhzW6D2iw9KIodi6wKpClWYxy++Mba+79Kt8=

View File

@@ -79,8 +79,8 @@ buildNpmPackage rec {
# degrade when unavailable.
npm rebuild node-pty
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
# Build all server packages in dependency order (defined in package.json)
npm run build:server
runHook postBuild
'';

5532
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.77",
"version": "0.1.90",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -24,6 +24,8 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/protocol",
"packages/client",
"packages/server",
"packages/app",
"packages/relay",
@@ -34,16 +36,32 @@
"scripts": {
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:server": "npm run build:server-deps && npm run dev:server:watch",
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:relay": "npm run build --workspace=@getpaseo/relay",
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present",
"format": "oxfmt .",
"format:files": "oxfmt",
@@ -52,6 +70,9 @@
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"knip": "knip",
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -63,7 +84,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:workspace-deps --workspace=@getpaseo/app && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
@@ -77,14 +98,16 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:publish:beta && npm run release:push",
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:publish:beta && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
@@ -111,6 +134,8 @@
"overrides": {
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"react-native-reanimated": "4.3.1",
"react-native-worklets": "0.8.3"
}
}

View File

@@ -1,9 +1,9 @@
name: Resubmit iOS for App Store review
# Standalone re-trigger for the App Store review submission step. The iOS
# binary is already uploaded to TestFlight via the main release-mobile flow;
# this workflow just runs the fastlane submit_review lane against the latest
# TestFlight build, without rebuilding or re-uploading.
# binary is already uploaded to TestFlight via the EAS GitHub app's tag-push
# build; this workflow just runs the fastlane submit_review lane against the
# latest TestFlight build, without rebuilding or re-uploading.
on:
workflow_dispatch:

View File

@@ -2,15 +2,12 @@
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
import { test } from "./fixtures";
import {
connectArchiveTabDaemonClient,
expectSessionsEmptyState,
openSessions,
} from "./helpers/archive-tab";
import { connectSeedClient } from "./helpers/seed-client";
import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab";
test.describe("Sessions screen empty state", () => {
test.beforeAll(async () => {
const client = await connectArchiveTabDaemonClient();
const client = await connectSeedClient();
try {
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
if (history.entries.length > 0) {

View File

@@ -1,10 +1,12 @@
import { test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
openAddProviderModal,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
const ACP_PROVIDER = {
@@ -12,19 +14,13 @@ const ACP_PROVIDER = {
name: "Hermes",
};
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
test.describe("ACP provider catalog", () => {
test("adds a catalog provider from settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getSeededServerId());
await openSettingsHost(page, getServerId());
// Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderModal(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);

View File

@@ -0,0 +1,28 @@
import { test } from "./fixtures";
import {
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
openAgentTimeline,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
} from "./helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await scrollTimelineUntilOlderHistoryIsReachable(page);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
});

View File

@@ -1,12 +1,12 @@
import { randomUUID } from "node:crypto";
import { test } from "./fixtures";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
clickSessionRow,
closeWorkspaceAgentTab,
connectArchiveTabDaemonClient,
createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
@@ -21,14 +21,14 @@ import {
} from "./helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
client = await connectSeedClient();
});
test.afterAll(async () => {

View File

@@ -0,0 +1,126 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgentAtMobileBreakpoint(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "bottom-sheet-reopen-",
title: "Bottom sheet reopen e2e",
initialPrompt: "Prepare a bottom sheet reopen test agent.",
});
await openAgentRoute(page, session);
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
timeout: 30_000,
});
await expectComposerVisible(page);
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
timeout: 30_000,
});
return session;
}
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await run();
} finally {
await session.cleanup();
}
}
function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
}
function bottomSheetHandle(page: Page) {
return page.getByRole("slider", { name: "Bottom sheet handle" }).first();
}
async function expectBottomSheetOpen(page: Page) {
await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 });
}
async function closeBottomSheetWithBackdrop(page: Page) {
const backdrop = bottomSheetBackdrop(page);
const handle = bottomSheetHandle(page);
// Tapping the backdrop is the close path under test, but on a loaded CI runner
// the model-selector sheet re-renders as its model list settles and Gorhom
// drops backdrop presses during that churn — so a tap (even retried) can fail
// to dismiss. Tap the backdrop first; if it survives, drag the handle down,
// which drives Gorhom's pan-to-close directly and is unaffected by the churn.
// The post-close guard below still protects the regression this test exists
// for: a sheet that dismisses, then re-presents.
await expect(async () => {
if (!(await backdrop.isVisible())) {
return;
}
const box = await backdrop.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + 24);
}
await page.waitForTimeout(150);
if (await backdrop.isVisible()) {
const handleBox = await handle.boundingBox();
if (handleBox) {
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 400, { steps: 8 });
await page.mouse.up();
}
}
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
// Guard against the regression where the sheet starts dismissing, then re-presents.
await page.waitForTimeout(500);
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}
async function openTabSwitcher(page: Page) {
const trigger = page.getByRole("button", { name: /Switch tabs/ });
await trigger.click();
await expectBottomSheetOpen(page);
}
async function openModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
await expectBottomSheetOpen(page);
await expect(
page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
}
async function openAndCloseTabSwitcherTwice(page: Page) {
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
}
async function openAndCloseModelSelectorTwice(page: Page) {
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
}
test.describe("mobile bottom sheet reopen", () => {
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseTabSwitcherTwice(page);
});
});
test("model selector can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseModelSelectorTwice(page);
});
});
});

View File

@@ -1,8 +1,6 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
expectSessionRowArchived,
expectWorkspaceTabHidden,
@@ -11,22 +9,12 @@ import {
} from "./helpers/archive-tab";
interface SlashCommandScenario {
agent: { id: string };
client: TerminalPerfDaemonClient;
cwd: string;
agentId: string;
title: string;
}
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function withOpenReadyMockAgent(
page: Page,
input: {
@@ -36,68 +24,23 @@ async function withOpenReadyMockAgent(
},
run: (scenario: SlashCommandScenario) => Promise<void>,
): Promise<void> {
const repo = await createTempGitRepo("client-slash-command-");
const client = await connectTerminalClient();
try {
await openProject(client, repo.path);
const agent = await createReadyMockAgent(client, {
cwd: repo.path,
title: input.title,
model: input.model,
modeId: input.modeId,
});
await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id });
await run({ agent, client, cwd: repo.path, title: input.title });
} finally {
await client.close();
await repo.cleanup();
}
}
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
const opened = await client.openProject(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
}
}
async function createReadyMockAgent(
client: TerminalPerfDaemonClient,
input: {
cwd: string;
title: string;
model?: string;
modeId?: string;
},
): Promise<{ id: string }> {
const agent = await client.createAgent({
provider: "mock",
cwd: input.cwd,
const session = await seedMockAgentWorkspace({
repoPrefix: "client-slash-command-",
title: input.title,
modeId: input.modeId ?? "load-test",
model: input.model ?? "ten-second-stream",
model: input.model,
modeId: input.modeId,
initialPrompt: "Prepare a client slash command test agent.",
});
return { id: agent.id };
}
async function openActiveAgentTab(
page: Page,
input: { cwd: string; agentId: string },
): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectWorkspaceTabVisible(page, input.agentId);
await expectComposerVisible(page);
try {
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
await expectComposerVisible(page);
await run({ agentId: session.agentId, title: input.title });
} finally {
await session.cleanup();
}
}
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
@@ -126,7 +69,7 @@ async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<v
await expect(
page.getByRole("button", { name: "Select model (Ten second stream)" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Select agent mode (load-test)" })).toBeVisible();
await expect(page.getByRole("button", { name: "Select agent mode (Load test)" })).toBeVisible();
}
async function createAgentFromReplacementDraft(page: Page): Promise<void> {
@@ -169,9 +112,9 @@ async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promis
test.describe("Client slash commands", () => {
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => {
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agentId, title }) => {
await runClientSlashCommand(page, "/quit");
await expectWorkspaceTabHidden(page, agent.id);
await expectWorkspaceTabHidden(page, agentId);
await expectAgentArchivedInSessions(page, title);
});
});
@@ -180,9 +123,9 @@ test.describe("Client slash commands", () => {
await withOpenReadyMockAgent(
page,
{ title: "Slash quit autocomplete e2e" },
async ({ agent, title }) => {
await selectClientSlashCommand(page, "/qu", "/quit");
await expectWorkspaceTabHidden(page, agent.id);
async ({ agentId, title }) => {
await selectClientSlashCommand(page, "/qu", "/exit");
await expectWorkspaceTabHidden(page, agentId);
await expectAgentArchivedInSessions(page, title);
},
);
@@ -192,12 +135,12 @@ test.describe("Client slash commands", () => {
await withOpenReadyMockAgent(
page,
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
async ({ agent, title }) => {
async ({ agentId, title }) => {
await runClientSlashCommand(page, "/clear");
await expectWorkspaceTabHidden(page, agent.id);
await expectWorkspaceTabHidden(page, agentId);
await expectReplacementDraftMatchesPreviousSetup(page);
await createAgentFromReplacementDraft(page);
await waitForReplacementAgentId(page, agent.id);
await waitForReplacementAgentId(page, agentId);
await expectAgentArchivedInSessions(page, title);
},
);

View File

@@ -1,16 +1,6 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
test.describe("Codex plan approval", () => {
test("shows a single actionable plan panel and removes it after implementation starts", async ({
@@ -18,29 +8,14 @@ test.describe("Codex plan approval", () => {
}) => {
test.setTimeout(180_000);
const repo = await createTempGitRepo("codex-plan-approval-");
const client = await connectTerminalClient();
const session = await seedMockAgentWorkspace({
repoPrefix: "codex-plan-approval-",
title: "Codex plan approval e2e",
initialPrompt: "Emit synthetic plan approval.",
});
try {
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Codex plan approval e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "Emit synthetic plan approval.",
});
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
repo.path,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await openAgentRoute(page, session);
await waitForPermissionPrompt(page, 120_000);
@@ -54,8 +29,7 @@ test.describe("Codex plan approval", () => {
});
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
} finally {
await client.close();
await repo.cleanup();
await session.cleanup();
}
});
});

View File

@@ -23,16 +23,12 @@ import {
expectGithubAttachmentPill,
openGithubWorkspace,
} from "./helpers/composer";
import {
connectNewWorkspaceDaemonClient,
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { createTempGitRepo } from "./helpers/workspace";
import { seedWorkspace } from "./helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
@@ -237,27 +233,23 @@ test.describe("Composer attachments", () => {
test("composer is locked while new workspace agent is being created", async ({ page }) => {
test.setTimeout(120_000);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
const serverId = getServerId();
const repo = await createTempGitRepo("attach-lock-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const daemonClient = await connectNewWorkspaceDaemonClient();
const workspace = await seedWorkspace({ repoPrefix: "attach-lock-" });
try {
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
targetWorkspacePath: workspace.workspaceId,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
const createButton = page
@@ -277,8 +269,7 @@ test.describe("Composer attachments", () => {
await expectComposerEditable(page);
} finally {
agentCreatedDelay.release();
await daemonClient.close().catch(() => undefined);
await repo.cleanup();
await workspace.cleanup();
}
});
});

View File

@@ -0,0 +1,483 @@
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
const TEST_COMMANDS = [
{
name: "tdd",
description: "Write a red test, verify it fails for the right reason, implement to green",
argumentHint: "",
},
{
name: "help",
description: "Show help for the current agent session and available slash commands",
argumentHint: "",
},
{
name: "hello",
description: "Insert a friendly greeting prompt into the current composer",
argumentHint: "",
},
{
name: "heapdump",
description: "Dump the JavaScript heap for local desktop debugging",
argumentHint: "",
},
{
name: "health",
description: "Show runtime health checks and connection diagnostics",
argumentHint: "",
},
{
name: "history",
description: "Summarize recent session history",
argumentHint: "",
},
{
name: "handoff",
description: "Prepare a complete handoff note for another agent",
argumentHint: "[agent]",
},
{
name: "hover",
description: "Audit hover behavior in desktop web surfaces",
argumentHint: "",
},
{
name: "harness",
description: "Inspect the local test harness configuration",
argumentHint: "",
},
{
name: "hydrate",
description: "Refresh persisted state used by the current workspace",
argumentHint: "",
},
{
name: "highlight",
description: "Highlight important changes in the active diff",
argumentHint: "",
},
{
name: "home",
description: "Navigate back to the workspace home surface",
argumentHint: "",
},
{
name: "host",
description: "Inspect host connection metadata",
argumentHint: "",
},
] as const;
interface PopoverFrame {
exists: boolean;
top: number;
bottom: number;
height: number;
opacity: number;
display: string;
visibility: string;
timestamp: number;
}
interface PopoverFrameRecorderWindow extends Window {
__composerAutocompleteFrames?: PopoverFrame[];
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
return page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
);
}
async function installListCommandsStub(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
server.send(message);
});
server.onMessage((message) => {
if (typeof message !== "string") {
ws.send(message);
return;
}
try {
const parsed = JSON.parse(message) as {
type?: string;
message?: {
type?: string;
payload?: {
commands?: unknown;
error?: string | null;
};
};
};
if (
parsed.type === "session" &&
parsed.message?.type === "list_commands_response" &&
parsed.message.payload
) {
parsed.message.payload.commands = TEST_COMMANDS;
parsed.message.payload.error = null;
ws.send(JSON.stringify(parsed));
return;
}
} catch {
// Forward non-JSON frames unchanged.
}
ws.send(message);
});
});
}
async function openReadyMockAgent(
page: Page,
options?: { expectWorkspaceTab?: boolean },
): Promise<{
cleanup: () => Promise<void>;
}> {
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-popover-",
title: "Autocomplete popover regression",
});
try {
await openAgentRoute(page, session);
if (options?.expectWorkspaceTab !== false) {
await expectWorkspaceTabVisible(page, session.agentId);
}
await expectComposerVisible(page);
return { cleanup: session.cleanup };
} catch (error) {
await session.cleanup();
throw error;
}
}
async function visiblePopoverBox(
page: Page,
): Promise<{ top: number; bottom: number; height: number }> {
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover).toBeVisible({ timeout: 30_000 });
await expect
.poll(
async () =>
popover.evaluate((element) => {
const style = window.getComputedStyle(element);
return Number(style.opacity);
}),
{ timeout: 30_000 },
)
.toBeGreaterThan(0.95);
const box = await popover.boundingBox();
if (!box) {
throw new Error("Autocomplete popover did not produce a bounding box.");
}
return {
top: box.y,
bottom: box.y + box.height,
height: box.height,
};
}
async function startPopoverFrameRecorder(page: Page): Promise<void> {
await page.evaluate(() => {
const win = window as PopoverFrameRecorderWindow;
win.__composerAutocompleteFrames = [];
let active = true;
const record = () => {
if (!active) return;
const element = document.querySelector('[data-testid="composer-autocomplete-popover"]');
if (element instanceof HTMLElement) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
win.__composerAutocompleteFrames?.push({
exists: true,
top: rect.top,
bottom: rect.bottom,
height: rect.height,
opacity: Number(style.opacity),
display: style.display,
visibility: style.visibility,
timestamp: performance.now(),
});
} else {
win.__composerAutocompleteFrames?.push({
exists: false,
top: 0,
bottom: 0,
height: 0,
opacity: 0,
display: "none",
visibility: "hidden",
timestamp: performance.now(),
});
}
window.requestAnimationFrame(record);
};
win.__stopComposerAutocompleteFrameRecorder = () => {
active = false;
};
window.requestAnimationFrame(record);
});
}
async function stopPopoverFrameRecorder(page: Page): Promise<PopoverFrame[]> {
return page.evaluate(() => {
const win = window as PopoverFrameRecorderWindow;
win.__stopComposerAutocompleteFrameRecorder?.();
return win.__composerAutocompleteFrames ?? [];
});
}
function visiblePopoverFrames(frames: PopoverFrame[]): PopoverFrame[] {
return frames.filter(
(frame) =>
frame.exists &&
frame.height > 0 &&
frame.opacity > 0.95 &&
frame.display !== "none" &&
frame.visibility !== "hidden",
);
}
function formatFrame(frame: PopoverFrame | undefined): string {
if (!frame) return "none";
return JSON.stringify({
top: Math.round(frame.top),
bottom: Math.round(frame.bottom),
height: Math.round(frame.height),
opacity: frame.opacity,
});
}
function expectPopoverFramesStable(frames: PopoverFrame[]): void {
const visibleFrames = visiblePopoverFrames(frames);
expect(visibleFrames.length).toBeGreaterThan(0);
const finalFrame = visibleFrames[visibleFrames.length - 1];
const jumpingFrame = visibleFrames.find(
(frame) =>
Math.abs(frame.top - finalFrame.top) > 4 ||
Math.abs(frame.bottom - finalFrame.bottom) > 4 ||
Math.abs(frame.height - finalFrame.height) > 4,
);
expect(
jumpingFrame,
`expected first visible popover paint to be stable; first=${formatFrame(
visibleFrames[0],
)} jumping=${formatFrame(jumpingFrame)} final=${formatFrame(finalFrame)}`,
).toBeUndefined();
}
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
const firstVisibleIndex = frames.findIndex(
(frame) =>
frame.exists &&
frame.height > 0 &&
frame.opacity > 0.95 &&
frame.display !== "none" &&
frame.visibility !== "hidden",
);
expect(firstVisibleIndex).toBeGreaterThanOrEqual(0);
const hiddenFrame = frames
.slice(firstVisibleIndex)
.find((frame) => !frame.exists || frame.opacity < 0.95);
expect(
hiddenFrame,
`expected mounted popover to stay visible while filtering; hidden=${formatFrame(hiddenFrame)}`,
).toBeUndefined();
}
test.describe("Composer autocomplete", () => {
test("does not flash at the wrong position on the first slash command paint", async ({
page,
}) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.click();
await startPopoverFrameRecorder(page);
await page.keyboard.type("/");
await expect(page.getByText("/help", { exact: true })).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(250);
const frames = await stopPopoverFrameRecorder(page);
expectPopoverFramesStable(frames);
} finally {
await agent.cleanup();
}
});
test("does not jump when deleting a slash command search", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/he");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const beforeDelete = await visiblePopoverBox(page);
await input.press("Backspace");
await expect(input).toHaveValue("/h");
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const afterDelete = await visiblePopoverBox(page);
expect(Math.abs(afterDelete.bottom - beforeDelete.bottom)).toBeLessThanOrEqual(4);
expect(afterDelete.height).toBeGreaterThan(beforeDelete.height);
} finally {
await agent.cleanup();
}
});
test("shrinks to filtered slash command results without moving the bottom edge", async ({
page,
}) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const allCommands = await visiblePopoverBox(page);
await input.fill("/tdd");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const oneCommand = await visiblePopoverBox(page);
expect(Math.abs(oneCommand.bottom - allCommands.bottom)).toBeLessThanOrEqual(4);
expect(oneCommand.height).toBeLessThan(allCommands.height - 40);
} finally {
await agent.cleanup();
}
});
test("stays visible while filtering slash command results", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await startPopoverFrameRecorder(page);
await page.keyboard.type("tdd", { delay: 40 });
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(250);
const frames = await stopPopoverFrameRecorder(page);
expectPopoverDoesNotDisappearAfterFirstVisible(frames);
} finally {
await agent.cleanup();
}
});
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const composerBox = await page.getByTestId("message-input-root").boundingBox();
const popoverBox = await popover.boundingBox();
expect(composerBox).not.toBeNull();
expect(popoverBox).not.toBeNull();
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
} finally {
await agent.cleanup();
}
});
test.describe("compact sidebar layering", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
const topTestId = await getTopTestIdAtPoint(
page,
popoverBox!.x + popoverBox!.width / 2,
popoverBox!.y + popoverBox!.height / 2,
);
expect(topTestId).not.toBe("composer-autocomplete-popover");
} finally {
await agent.cleanup();
}
});
});
});

View File

@@ -1,5 +1,6 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import {
loadRealDaemonState,
injectDesktopBridge,
@@ -17,20 +18,12 @@ import {
expectDaemonStatusVersion,
} from "./helpers/desktop-updates";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
// No Playwright Electron runner exists; we simulate the desktop bridge via
// addInitScript so Electron-gated UI activates without a real Electron process.
test.describe("Desktop updates", () => {
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getSeededServerId(),
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
});
@@ -41,7 +34,7 @@ test.describe("Desktop updates", () => {
test("clicking install shows the installing state on the callout", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getSeededServerId(),
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
slowInstall: true,
@@ -58,7 +51,7 @@ test.describe("Desktop daemon management", () => {
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
page,
}) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
@@ -74,7 +67,7 @@ test.describe("Desktop daemon management", () => {
});
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
@@ -89,7 +82,7 @@ test.describe("Desktop daemon management", () => {
});
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
@@ -106,7 +99,7 @@ test.describe("Desktop daemon management", () => {
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
page,
}) => {
const serverId = getSeededServerId();
const serverId = getServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,
@@ -124,7 +117,7 @@ test.describe("Desktop daemon management", () => {
});
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,

View File

@@ -0,0 +1,505 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
import { test, expect } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
}
interface CleanupTask {
run: () => Promise<void>;
}
const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
}
interface UseMountedTabSetResult {
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
const { activeTabId, allTabIds, cap } = input;
const allTabIdsKey = allTabIds.join("\\u0000");
const availableTabIds = useMemo(() => {
void allTabIdsKey;
return new Set(allTabIds);
}, [allTabIds, allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
if (activeTabId && availableTabIds.has(activeTabId)) {
const existingIndex = nextLru.indexOf(activeTabId);
if (existingIndex >= 0) {
nextLru.splice(existingIndex, 1);
}
nextLru.unshift(activeTabId);
}
if (nextLru.length > cap) {
nextLru.length = cap;
}
lruRef.current = nextLru;
setMountedTabIds((previousMountedTabIds) => {
const nextMountedTabIds = new Set(nextLru);
return setsEqual(previousMountedTabIds, nextMountedTabIds)
? previousMountedTabIds
: nextMountedTabIds;
});
}, [activeTabId, availableTabIds, cap]);
return { mountedTabIds };
}
`;
const AFTER = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
}
interface UseMountedTabSetResult {
mountedTabIds: Set<string>;
}
interface DeriveRenderMountedTabIdsInput {
activeTabId: string | null;
availableTabIds: Set<string>;
cap: number;
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
function deriveRenderMountedTabIds(input: DeriveRenderMountedTabIdsInput): Set<string> {
const { activeTabId, availableTabIds, cap, mountedTabIds } = input;
if (!activeTabId || !availableTabIds.has(activeTabId) || mountedTabIds.has(activeTabId)) {
return mountedTabIds;
}
const next = new Set<string>([activeTabId]);
const maxSize = Math.max(1, cap);
for (const tabId of mountedTabIds) {
if (next.size >= maxSize) {
break;
}
if (availableTabIds.has(tabId)) {
next.add(tabId);
}
}
return next;
}
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
const { activeTabId, allTabIds, cap } = input;
const allTabIdsKey = allTabIds.join("\\u0000");
const availableTabIds = useMemo(() => {
void allTabIdsKey;
return new Set(allTabIds);
}, [allTabIds, allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
const renderMountedTabIds = useMemo(
() =>
deriveRenderMountedTabIds({
activeTabId,
availableTabIds,
cap,
mountedTabIds,
}),
[activeTabId, availableTabIds, cap, mountedTabIds],
);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
if (activeTabId && availableTabIds.has(activeTabId)) {
const existingIndex = nextLru.indexOf(activeTabId);
if (existingIndex >= 0) {
nextLru.splice(existingIndex, 1);
}
nextLru.unshift(activeTabId);
}
if (nextLru.length > cap) {
nextLru.length = cap;
}
lruRef.current = nextLru;
setMountedTabIds((previousMountedTabIds) => {
const nextMountedTabIds = new Set(nextLru);
return setsEqual(previousMountedTabIds, nextMountedTabIds)
? previousMountedTabIds
: nextMountedTabIds;
});
}, [activeTabId, availableTabIds, cap]);
return { mountedTabIds: renderMountedTabIds };
}
`;
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task.run();
}
});
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 9);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectDiffCodeFontSize(page, 9);
await expectVisibleDiffRowsAligned(page);
await expectDiffCodeTextAlignedWithGutterText(page, [
{
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
},
{ codeText: "return next;", lineNumber: "55" },
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
]);
await expectHoverCommentButtonAlignedWithCodeLine(page, {
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
});
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await changeCodeFontSizeFromSettings(page, 18);
await returnToWorkspaceChanges(page);
await scrollToLowerUnwrappedDiffRows(page);
await expectDiffCodeFontSize(page, 18);
await expectVisibleDiffRowsShareTypography(page);
await expectVisibleDiffRowsAligned(page);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
localStorage.setItem(
settingsKey,
JSON.stringify({
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: 16,
codeFontSize: fontSize,
syntaxTheme: "one",
}),
);
},
{ settingsKey: APP_SETTINGS_KEY, fontSize: codeFontSize },
);
}
async function useUnwrappedDiffLines(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const actualFontSize = await page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
expect(actualFontSize).toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
}
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
}
async function readVisibleDiffRowGeometry(page: Page): Promise<{
maxDelta: number;
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
rows: {
index: number;
gutterTop: number;
codeTop: number;
delta: number;
gutterLineHeight: number;
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
);
const codes = readRows("diff-code-row-", "diff-code-text-");
const rows = codes
.map((code) => {
const gutter = gutters.get(code.index);
if (!gutter) {
throw new Error(`Missing gutter row ${code.index}`);
}
return {
index: code.index,
gutterTop: gutter.top,
codeTop: code.top,
delta: Math.abs(code.top - gutter.top),
gutterLineHeight: gutter.lineHeight,
codeLineHeight: code.lineHeight,
};
})
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
return {
maxDelta: Math.max(...rows.map((row) => row.delta)),
mismatchedTypography: rows
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
.map((row) => ({
index: row.index,
gutterLineHeight: row.gutterLineHeight,
codeLineHeight: row.codeLineHeight,
})),
rows,
};
});
}
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
const opened = await client.openProject(repo.path);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
}
return { id: opened.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await openChangesInVisibleExplorer(page);
await page.getByTestId("diff-file-0").click();
await expectExpandedMountedTabDiff(page);
}
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-tab-changes")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
}
async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): Promise<void> {
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
await page.getByLabel("Code font size").fill(String(codeFontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
}
async function returnToWorkspaceChanges(page: Page): Promise<void> {
await page.getByTestId("settings-back-to-workspace").click();
await waitForWorkspaceTabsVisible(page);
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
}
async function expectDiffCodeTextAlignedWithGutterText(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<void> {
const geometries = await readDiffTextGeometry(page, lines);
for (const geometry of geometries) {
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
}
}
async function expectHoverCommentButtonAlignedWithCodeLine(
page: Page,
line: { codeText: string; lineNumber: string },
): Promise<void> {
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
if (!target) {
throw new Error(`Could not find target line ${line.lineNumber}`);
}
await page.getByTestId(`diff-code-row-${target.index}`).hover();
const geometry = await page
.getByTestId(`diff-gutter-action-${target.index}`)
.evaluate((action, expectedCodeCenterY) => {
const rect = action.getBoundingClientRect();
return {
actionCenterY: rect.top + rect.height / 2,
codeCenterY: expectedCodeCenterY,
};
}, target.codeCenterY);
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
}
async function readDiffTextGeometry(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
> {
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
if (!root) {
throw new Error("Changes panel is not mounted");
}
const readIndexedElements = (prefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
(element) => {
const testId = element.getAttribute("data-testid") ?? "";
return { index: Number(testId.slice(prefix.length)), element };
},
);
const gutterTexts = readIndexedElements("diff-gutter-text-");
const codeTexts = readIndexedElements("diff-code-text-");
return targets.map((target) => {
const gutter = gutterTexts.find(
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
);
if (!gutter) {
throw new Error(`Could not find gutter line ${target.lineNumber}`);
}
const code = codeTexts.find(
({ index, element }) =>
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
);
if (!code) {
throw new Error(`Could not find code row ${target.codeText}`);
}
const codeRect = code.element.getBoundingClientRect();
const gutterRect = gutter.element.getBoundingClientRect();
return {
index: gutter.index,
codeText: target.codeText,
codeTop: codeRect.top,
gutterTop: gutterRect.top,
codeCenterY: codeRect.top + codeRect.height / 2,
};
});
}, lines);
}

View File

@@ -9,41 +9,31 @@ import {
openFileFromExplorer,
} from "./helpers/file-explorer";
import { gotoWorkspace } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: WorkspaceSetupDaemonClient;
let workspace: SeededWorkspace;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
files: [
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
{ path: "docs/guide.md", content: "# Guide\n" },
],
workspace = await seedWorkspace({
repoPrefix: "file-explorer-collapse-",
repo: {
files: [
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
{ path: "docs/guide.md", content: "# Guide\n" },
],
},
});
seedClient = await connectWorkspaceSetupClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) {
throw new Error(result.error ?? "Failed to seed workspace");
}
workspaceId = result.workspace.id;
});
test.afterAll(async () => {
await seedClient?.close();
await tempRepo?.cleanup();
await workspace?.cleanup();
});
test.describe("File explorer collapse", () => {
test("collapses an opened image file parent folder and still expands other folders", async ({
page,
}) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "assets");

View File

@@ -1,4 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
@@ -17,20 +18,8 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
},
paseoE2ESetup: [
async ({ page }, provide, testInfo) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const daemonPort = getE2EDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
);
}
if (daemonPort === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
);
}
if (!metroPort) {
throw new Error(
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",

View File

@@ -188,7 +188,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null;
let fakeToolBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
@@ -209,8 +209,8 @@ interface OfferPayload {
relay: { endpoint: string };
}
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
async function createFakeToolBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
@@ -284,6 +284,27 @@ forwardToRealGh();
`,
);
await chmod(ghPath, 0o755);
const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH;
if (recordPath) {
fs.appendFileSync(recordPath, JSON.stringify({
command: path.basename(process.argv[1]),
args: process.argv.slice(2),
cwd: process.cwd(),
at: Date.now()
}) + "\\n");
}
`;
for (const editorCommand of ["cursor", "code"]) {
const editorPath = path.join(binDir, editorCommand);
await writeFile(editorPath, fakeEditorSource);
await chmod(editorPath, 0o755);
}
return binDir;
}
@@ -300,7 +321,7 @@ function ensureRelayBuildArtifact(repoRoot: string): void {
}
console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync("npm run build --workspace=@getpaseo/relay", {
execSync("npm run build:relay", {
cwd: repoRoot,
stdio: "inherit",
});
@@ -516,13 +537,22 @@ async function awaitRelayReady(
}
}
async function startRelay(): Promise<number> {
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!excludedPorts.has(port)) {
return port;
}
}
}
async function startRelay(excludedPorts: Set<number>): Promise<number> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePort();
const relayPort = await getAvailablePortExcluding(excludedPorts);
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
@@ -599,7 +629,8 @@ interface DaemonSpawnArgs {
relayPort: number;
metroPort: number;
paseoHome: string;
fakeGhBinDir: string;
fakeToolBinDir: string;
editorRecordPath: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
}
@@ -613,8 +644,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
@@ -678,9 +710,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
if (fakeToolBinDir) {
await rm(fakeToolBinDir, { recursive: true, force: true });
fakeToolBinDir = null;
}
}
@@ -694,7 +726,8 @@ export default async function globalSetup() {
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
fakeGhBinDir = await createFakeGhBin();
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
fakeToolBinDir = await createFakeToolBin();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
@@ -705,14 +738,15 @@ export default async function globalSetup() {
const dictation = await resolveDictationConfig();
try {
const relayPort = await startRelay();
const relayPort = await startRelay(new Set([port, metroPort]));
metroProcess = startMetro(metroPort, metroLineBuffer);
daemonProcess = startDaemon({
port,
relayPort,
metroPort,
paseoHome,
fakeGhBinDir,
fakeToolBinDir,
editorRecordPath,
dictation,
buffer: daemonLineBuffer,
});
@@ -742,6 +776,7 @@ export default async function globalSetup() {
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
process.env.E2E_PASEO_HOME = paseoHome;
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);

View File

@@ -1,9 +1,4 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -14,157 +9,6 @@ export interface ScrollMetrics {
distanceFromBottom: number;
}
export interface SeededAgent {
id: string;
title: string;
expectedTailText: string;
url: string;
workspaceUrl: string;
}
export interface DaemonClientInstance {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
}
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function buildReplyBlock(label: string, lineCount = 14): string {
return Array.from({ length: lineCount }, (_, index) => {
const line = (index + 1).toString().padStart(2, "0");
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
}).join("\n");
}
function buildProtocolMessage(label: string, lineCount = 14): string {
return [
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
"Do not add extra words, bullets, markdown fences, or tool calls.",
"REPLY:",
buildReplyBlock(label, lineCount),
].join("\n");
}
function buildReplyMessage(label: string, lineCount = 14): string {
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
}
export function createReplyTurn(label: string): {
message: string;
expectedReply: string;
} {
return {
message: buildReplyMessage(label),
expectedReply: buildReplyBlock(label),
};
}
interface DaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: DaemonClientConfig) => DaemonClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function seedBottomAnchorAgent(input: {
client: DaemonClientInstance;
cwd: string;
title?: string;
turnCount?: number;
lineCount?: number;
}): Promise<SeededAgent> {
const title = input.title ?? `bottom-anchor-${Date.now()}`;
const turnCount = Math.max(3, input.turnCount ?? 5);
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
title,
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
});
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
);
}
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
for (let index = 1; index < turnCount; index += 1) {
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
expectedTailText = buildReplyBlock(label, lineCount);
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
const finish = await input.client.waitForFinish(created.id, 120000);
if (finish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
);
}
}
return {
id: created.id,
title,
expectedTailText,
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
};
}
function getVisibleChatScroll(page: Page) {
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
}
@@ -201,48 +45,6 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
});
}
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
const scrollViewport = getVisibleChatScroll(page);
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
let remaining = Math.max(0, pixels);
while (remaining > 0) {
const delta = Math.min(240, remaining);
await scrollViewport.evaluate((element: Element, step: number) => {
const scrollContainer = element as HTMLElement;
scrollContainer.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -step,
bubbles: true,
cancelable: true,
}),
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
}, delta);
remaining -= delta;
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
return;
}
}
}
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 60000,
});
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
if (expectedTailText) {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.contentHeight;
})
.toBeGreaterThan(0);
}
}
export async function expectNearBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
@@ -252,15 +54,6 @@ export async function expectNearBottom(page: Page): Promise<void> {
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function expectDetachedFromBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.distanceFromBottom;
})
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number,
@@ -273,11 +66,3 @@ export async function waitForContentGrowth(
.toBeGreaterThan(previousContentHeight);
return readScrollMetrics(page);
}
export async function getChatContainerKey(page: Page): Promise<string | null> {
return getVisibleChatScroll(page).evaluate((element) => {
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
}

View File

@@ -1,8 +1,5 @@
import { expect, type Page } from "@playwright/test";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
import { escapeRegex } from "./regex";
export const gotoAppShell = async (page: Page) => {
await page.goto("/");

View File

@@ -1,9 +1,8 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { getE2EDaemonPort } from "./daemon-port";
import { getServerId } from "./server-id";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
buildHostAgentDetailRoute,
@@ -17,100 +16,40 @@ export interface ArchiveTabAgent {
cwd: string;
}
interface ArchiveTabDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId?: string;
modeId: string;
cwd: string;
title: string;
initialPrompt?: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
function buildSeededStoragePayload() {
const nowIso = new Date().toISOString();
return {
daemon: buildSeededHost({
serverId: getServerId(),
endpoint: `127.0.0.1:${getDaemonPort()}`,
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
interface ArchiveTabDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
/**
* The slice of a daemon client `createIdleAgent` needs: spawn an agent and await
* its idle upsert. The shared seed client satisfies it, so a spec can seed an
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
createAgent(options: {
provider: string;
model: string;
modeId: string;
cwd: string;
title: string;
}): Promise<{ id: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
}
export async function createIdleAgent(
client: ArchiveTabDaemonClient,
client: IdleAgentSeedClient,
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
@@ -136,7 +75,7 @@ export async function createIdleAgent(
}
export async function archiveAgentFromDaemon(
client: ArchiveTabDaemonClient,
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
agentId: string,
): Promise<void> {
await client.archiveAgent(agentId);

View File

@@ -1,9 +1,10 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { getServerId } from "./server-id";
import { waitForTabBar } from "./launcher";
function composerInput(page: Page) {
@@ -145,7 +146,7 @@ export async function selectGithubOption(
}
export interface MockAgentSetup {
client: TerminalPerfDaemonClient;
client: SeedDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
}
@@ -154,11 +155,10 @@ export async function startRunningMockAgent(
page: Page,
opts: { prefix: string; model: string; prompt: string },
): Promise<MockAgentSetup> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
const serverId = getServerId();
const repo = await createTempGitRepo(opts.prefix);
const client = await connectTerminalClient();
const client = await connectSeedClient();
const opened = await client.openProject(repo.path);
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
const agent = await client.createAgent({

View File

@@ -0,0 +1,55 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { getE2EDaemonPort } from "./daemon-port";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
new (config: ClientConfig) => ClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/client/dist/daemon-client.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ClientConfig) => ClientInstance;
};
return mod.DaemonClient;
}
interface E2EDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
}
function resolveDaemonWsUrl(): string {
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
}
export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
}
/**
* Connects an in-test daemon client over the isolated E2E daemon's WebSocket.
* The port-6767 guard keeps tests off the developer daemon. Each helper passes
* its own typed client interface as the generic.
*/
export async function connectDaemonClient<ClientInstance extends { connect(): Promise<void> }>(
options: ConnectDaemonClientOptions,
): Promise<ClientInstance> {
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
const client = new DaemonClient({
url: resolveDaemonWsUrl(),
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
clientType: "cli",
appVersion: options.appVersion,
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
return client;
}

View File

@@ -0,0 +1,38 @@
import { escapeRegex } from "./regex";
/**
* Resolves the isolated E2E daemon's port, which Playwright's globalSetup
* publishes into the environment before any spec runs. Helpers and specs that
* build daemon WebSocket URLs, route patterns, or host endpoints share this
* accessor instead of re-reading the env var.
*
* The port-6767 guard is a hard guardrail: 6767 is the developer's default
* daemon, which manages real agents. The e2e port is never legitimately 6767,
* so refusing it here keeps every test off the developer daemon.
*/
export function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
if (port === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon (6767).");
}
return port;
}
/**
* Playwright `routeWebSocket` matcher for a WebSocket on `port`. Matches the
* `:<port>` segment at a word boundary, so it catches the URL regardless of
* host or path. Use this when intercepting connections to an arbitrary port
* (e.g. blocking an unreachable test host); for the E2E daemon itself, prefer
* `daemonWsRoutePattern()`.
*/
export function wsRoutePatternForPort(port: string): RegExp {
return new RegExp(`:${escapeRegex(port)}\\b`);
}
/** `routeWebSocket` matcher for the isolated E2E daemon's WebSocket. */
export function daemonWsRoutePattern(): RegExp {
return wsRoutePatternForPort(getE2EDaemonPort());
}

View File

@@ -1,7 +1,9 @@
import { readFileSync } from "node:fs";
import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsHost } from "./settings";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost, openSettingsHostSection } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -26,9 +28,8 @@ export interface RealDaemonState {
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
*/
export async function loadRealDaemonState(): Promise<RealDaemonState> {
const port = process.env.E2E_DAEMON_PORT;
const port = getE2EDaemonPort();
const paseoHome = process.env.E2E_PASEO_HOME;
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
@@ -64,6 +65,21 @@ export interface DesktopBridgeConfig {
* false so tests that only assert copy don't inadvertently trigger state changes.
*/
confirmShouldAccept?: boolean;
editorTargets?: DesktopEditorTargetConfig[];
editorRecordPath?: string;
}
interface DesktopEditorTargetConfig {
id: string;
label: string;
kind: "editor" | "file-manager";
}
interface DesktopEditorOpenRecord {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
export interface ConfirmDialogCall {
@@ -74,6 +90,7 @@ export interface ConfirmDialogCall {
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
}
}
@@ -87,6 +104,15 @@ declare global {
* can assert dialog copy without depending on window.confirm concatenation.
*/
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
if (config.editorRecordPath) {
await page.exposeFunction(
"__recordDesktopEditorOpen",
async (input: DesktopEditorOpenRecord) => {
await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8");
},
);
}
await page.addInitScript((cfg) => {
// Mutable state shared across IPC calls within this page
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
@@ -108,7 +134,19 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
};
}
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
const desktopBridge: {
platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
dialog: {
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
};
getPendingOpenProject: () => Promise<string | null>;
events: { on: () => Promise<() => void> };
editor?: {
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
};
} = {
platform: "darwin",
invoke: async (command: string, args?: Record<string, unknown>) => {
if (command === "check_app_update") {
@@ -202,12 +240,26 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
if (cfg.editorTargets) {
desktopBridge.editor = {
listTargets: async () => cfg.editorTargets ?? [],
openTarget: async (input: DesktopEditorOpenRecord) => {
await window.__recordDesktopEditorOpen?.(input);
},
};
}
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
}, config);
}
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);
// The daemon-lifecycle card moved to the Host section in the flat-settings
// layout; navigate there before asserting it.
await openSettingsHostSection(page, serverId, "host");
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000,
});

View File

@@ -1,17 +1,10 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { getServerId } from "./server-id";
// ─── Navigation ────────────────────────────────────────────────────────────
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
/** Navigate to a workspace and wait for the tab bar to appear. */
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), cwd);

View File

@@ -0,0 +1,69 @@
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
import { getServerId } from "./server-id";
export interface MockAgentWorkspace {
agentId: string;
cwd: string;
client: SeedDaemonClient;
cleanup(): Promise<void>;
}
export interface MockAgentOptions {
repoPrefix: string;
title: string;
initialPrompt?: string;
model?: string;
modeId?: string;
featureValues?: Record<string, unknown>;
}
/**
* Seeds a temp git repo, opens it as a project, and creates a ready mock-provider
* agent in it via the daemon. Returns the agent id plus a cleanup that closes the
* client and removes the repo. Pair with {@link openAgentRoute} to drive the UI.
*/
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
try {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "ten-second-stream",
initialPrompt: options.initialPrompt,
featureValues: options.featureValues,
});
return {
agentId: agent.id,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
} catch (error) {
await workspace.cleanup();
throw error;
}
}
export function buildAgentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
export async function openAgentRoute(
page: Page,
input: { cwd: string; agentId: string },
): Promise<void> {
await page.goto(buildAgentRoute(input.cwd, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
}

View File

@@ -1,14 +1,12 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
type NewWorkspaceDaemonClient = Pick<
ServerDaemonClient,
InternalDaemonClient,
| "archivePaseoWorktree"
| "archiveWorkspace"
| "close"
@@ -17,13 +15,6 @@ type NewWorkspaceDaemonClient = Pick<
| "openProject"
>;
interface NewWorkspaceDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
export interface OpenedProject {
@@ -33,34 +24,6 @@ export interface OpenedProject {
workspaceName: string;
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
};
return mod.DaemonClient;
}
function requireWorkspace(payload: OpenProjectPayload) {
if (payload.error) {
throw new Error(payload.error);
@@ -83,16 +46,9 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-new-workspace-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
return connectDaemonClient<NewWorkspaceDaemonClient>({
clientIdPrefix: "app-e2e-new-workspace",
});
await client.connect();
return client;
}
export async function openProjectViaDaemon(
@@ -168,14 +124,30 @@ export async function openNewWorkspaceComposer(
});
}
export async function clickNewWorkspaceButton(
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
await page.getByTestId("sidebar-new-workspace").click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
export async function expectNewWorkspaceProjectSelected(
page: Page,
input: { projectKey: string; projectDisplayName: string; prompt?: string },
projectDisplayName: string,
): Promise<void> {
const projectPicker = page.getByRole("button", { name: "Workspace project" });
await expect(projectPicker).toBeVisible({ timeout: 30_000 });
await expect(projectPicker).toContainText(projectDisplayName);
}
export async function submitNewWorkspacePrompt(
page: Page,
prompt = "Hello from e2e",
): Promise<void> {
await openNewWorkspaceComposer(page, input);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.fill(input.prompt ?? "Hello from e2e");
await composer.fill(prompt);
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
@@ -183,6 +155,14 @@ export async function clickNewWorkspaceButton(
await createButton.click();
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string; prompt?: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
await submitNewWorkspacePrompt(page, input.prompt);
}
export async function openStartingRefPicker(page: Page): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
@@ -221,7 +201,9 @@ export async function selectPickerOptionByKeyboard(page: Page, label: string): P
const searchInput = page.getByPlaceholder("Search branches and PRs");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await page.keyboard.type(label);
await page.keyboard.press("ArrowDown");
await expect(page.getByTestId(`new-workspace-ref-picker-branch-${label}`)).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press("Enter");
}
@@ -256,7 +238,13 @@ export async function expectComposerGithubAttachmentPill(
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
input: {
serverId: string;
previousWorkspaceId: string;
projectDisplayName: string;
assertSidebarRow?: boolean;
assertHeader?: boolean;
},
): Promise<{ workspaceId: string }> {
// Wait for URL to redirect to the newly created workspace.
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
@@ -274,15 +262,19 @@ export async function assertNewWorkspaceSidebarAndHeader(
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
}
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
if (input.assertSidebarRow !== false) {
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
}
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId),
subtitle: input.projectDisplayName,
});
if (input.assertHeader !== false) {
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId),
subtitle: input.projectDisplayName,
});
}
return { workspaceId };
}
@@ -327,12 +319,7 @@ export interface AgentCreatedDelayControl {
export async function delayBrowserAgentCreatedStatus(
page: Page,
): Promise<AgentCreatedDelayControl> {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const daemonPortPattern = daemonWsRoutePattern();
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { daemonWsRoutePattern } from "./daemon-port";
// --- Navigation ---
@@ -170,19 +171,13 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
// --- WebSocket helpers ---
function buildDaemonPortPattern(): RegExp {
const port = process.env.E2E_DAEMON_PORT;
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
}
// Proxies all daemon WS traffic transparently until a read_project_config_request
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
// connections pass through so the Reload action can succeed.
export async function installReadTransportFailure(page: Page): Promise<void> {
let armed = true;
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
@@ -230,7 +225,7 @@ export async function installDaemonConnectionGate(
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1001 });
return;

View File

@@ -0,0 +1,61 @@
import { expect, type Page } from "@playwright/test";
export async function waitForQuestionPrompt(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByTestId("question-form-card").first()).toBeVisible({ timeout });
}
export async function expectCurrentQuestion(
page: Page,
input: { index: number; total: number; question: string },
): Promise<void> {
const card = page.getByTestId("question-form-card").first();
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
await expect(
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toHaveAttribute("aria-selected", "true");
}
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
}
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: option })
.click();
}
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
await expect(
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
).toHaveAttribute("aria-selected", "true");
}
export async function openQuestion(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
.click();
}
export async function fillQuestionAnswer(
page: Page,
input: { question: string; answer: string },
): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("textbox", { name: input.question })
.fill(input.answer);
}
export async function submitQuestionAnswers(page: Page): Promise<void> {
await page.getByTestId("question-form-primary-action").click();
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
}

View File

@@ -0,0 +1,8 @@
/**
* Escape a literal string so it can be embedded safely inside a `RegExp`.
* Used across the suite to build dynamic patterns from daemon ports, URLs,
* workspace routes, and user-visible text without regex injection.
*/
export function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,40 @@
import { type Page } from "@playwright/test";
/**
* Listens for outbound WebSocket "session" frames of a given inner message type
* and accumulates them. The returned array is populated in-place as frames arrive.
*/
export function captureWsSessionFrames<T extends Record<string, unknown>>(
page: Page,
messageType: string,
extract: (inner: Record<string, unknown>) => T,
): T[] {
const captured: T[] = [];
page.on("websocket", (ws) => {
ws.on("framesent", (frame) => {
const raw = frame.payload;
const text = typeof raw === "string" ? raw : raw.toString("utf8");
try {
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
if (outer.type === "session" && outer.message?.type === messageType) {
captured.push(extract(outer.message));
}
} catch {
// Ignore non-JSON and binary frames.
}
});
});
return captured;
}
export function renameModalInput(page: Page, testIdPrefix: string) {
return page.getByTestId(`${testIdPrefix}-input`);
}
export function renameModalSubmit(page: Page, testIdPrefix: string) {
return page.getByTestId(`${testIdPrefix}-submit`);
}
export function renameModalError(page: Page, testIdPrefix: string) {
return page.getByTestId(`${testIdPrefix}-error`);
}

View File

@@ -0,0 +1,341 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expectComposerEditable, submitMessage } from "./composer";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
import { getServerId } from "./server-id";
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
export type RewindFlowMode = "conversation" | "files" | "both";
export interface AgentHandle {
page: Page;
client: SeedDaemonClient;
agentId: string;
cwd: string;
provider: RewindFlowProvider;
}
export interface TranscriptMessage {
role: "user" | "assistant";
text: string | RegExp;
}
interface ProviderLaunchConfig {
provider: RewindFlowProvider;
model?: string;
thinkingOptionId?: string;
modeId?: string;
featureValues?: Record<string, unknown>;
}
const SEND_TIMEOUT_MS = 240_000;
const REWIND_TIMEOUT_MS = 120_000;
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
switch (provider) {
case "claude":
return { provider, model: "haiku", modeId: "bypassPermissions" };
case "codex":
return {
provider,
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
};
case "opencode":
return {
provider,
model: "opencode/big-pickle",
modeId: "build",
featureValues: { auto_accept: true },
};
case "pi":
return {
provider,
model: "openrouter/google/gemini-2.5-flash-lite",
thinkingOptionId: "medium",
};
}
}
function agentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
await page.goto(agentRoute(input.cwd, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await assertComposerIdle({ page });
}
function visibleChatMessages(page: Page) {
return page
.locator('[data-testid="agent-chat-scroll"]:visible')
.first()
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
}
async function transcript(
page: Page,
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
elements.map((element) => ({
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
| "user"
| "assistant",
text: (element.textContent ?? "")
.replace(/\s+/g, " ")
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
.trim(),
})),
);
return coalesceAssistantTurnSegments(rawMessages);
}
function coalesceAssistantTurnSegments(
messages: Array<{ role: "user" | "assistant"; text: string }>,
): Array<{ role: "user" | "assistant"; text: string }> {
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
for (const message of messages) {
const previous = transcriptMessages.at(-1);
if (message.role === "assistant" && previous?.role === "assistant") {
const joinedText =
previous.text && message.text
? `${previous.text}\n${message.text}`
: previous.text || message.text;
transcriptMessages[transcriptMessages.length - 1] = {
role: "assistant",
text: joinedText,
};
continue;
}
transcriptMessages.push(message);
}
return transcriptMessages.filter(
(message) => message.role !== "assistant" || message.text.length > 0,
);
}
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
if (typeof expected === "string") {
return actual === expected;
}
return expected.test(actual);
}
function formatExpectedMessage(message: TranscriptMessage): string {
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
return `${message.role}:${text}`;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
export async function launchAgent(input: {
page: Page;
provider: RewindFlowProvider;
cwd: string;
mode: "full-access";
}): Promise<AgentHandle> {
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: input.cwd,
stdio: "ignore",
});
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
const client = await connectSeedClient();
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
}
const agent = await client.createAgent({
...fullAccessConfig(input.provider),
cwd: input.cwd,
title: `rewind-flow-${input.provider}-${randomUUID()}`,
});
const handle = {
page: input.page,
client,
agentId: agent.id,
cwd: input.cwd,
provider: input.provider,
};
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
return handle;
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.close().catch(() => undefined);
}
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
const before = await transcript(handle.page);
await submitMessage(handle.page, text);
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
if (finish.status !== "idle") {
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
throw new Error(
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
);
}
if (finish.final?.lastError) {
throw new Error(finish.final.lastError);
}
await expect
.poll(async () => transcript(handle.page), { timeout: 30_000 })
.toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "user", text }),
expect.objectContaining({ role: "assistant" }),
]),
);
await expect
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
timeout: 30_000,
})
.toBeGreaterThanOrEqual(before.length + 2);
await assertComposerIdle(handle);
}
export async function assertChatTranscript(
handle: Pick<AgentHandle, "page">,
expected: TranscriptMessage[],
): Promise<void> {
await expect
.poll(
async () => {
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
return JSON.stringify(actual);
}
const matches = actual.every(
(message, index) =>
message.role === expected[index]?.role &&
expectedTextMatches(message.text, expected[index]!.text),
);
return matches ? "match" : JSON.stringify(actual);
},
{ timeout: 30_000 },
)
.toBe("match");
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
throw new Error(
`Expected ${expected.length} chat messages (${expected
.map(formatExpectedMessage)
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
);
}
}
export async function rewindMessage(
handle: AgentHandle,
userMessageIndex: number,
mode: RewindFlowMode,
): Promise<void> {
const beforeEpoch = await fetchTimelineEpoch(handle);
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
await expect(userMessage).toBeVisible({ timeout: 30_000 });
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
const userText = userMessages[userMessageIndex]?.text;
if (!userText) {
throw new Error(`No user message found at index ${userMessageIndex}`);
}
await userMessage
.getByText(new RegExp(escapeRegExp(userText)))
.first()
.hover();
const trigger = userMessage.getByTestId("rewind-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
await expect(modeItem).toBeVisible({ timeout: 10_000 });
await modeItem.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
if (mode !== "files") {
await waitForNextTimelineEpoch(handle, beforeEpoch);
}
await assertComposerIdle(handle);
}
export async function assertFileExists(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
}
export async function assertFileMissing(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
}
export async function assertFileContains(filePath: string, text: string): Promise<void> {
await assertFileExists(filePath);
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
}
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
await expectComposerEditable(handle.page);
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 30_000,
});
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
timeout: 30_000,
});
}
export async function cleanupRewindFlow(input: {
handle?: AgentHandle;
cwd: string;
}): Promise<void> {
if (input.handle) {
await closeAgent(input.handle);
}
rmSync(input.cwd, { recursive: true, force: true });
}
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
const client = handle.client as SeedDaemonClient & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "projected"; limit?: number },
) => Promise<{ epoch?: string }>;
};
const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "projected",
limit: 0,
});
return timeline.epoch;
}
async function waitForNextTimelineEpoch(
handle: AgentHandle,
beforeEpoch: string | undefined,
): Promise<void> {
await expect
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
.not.toBe(beforeEpoch);
}

View File

@@ -0,0 +1,145 @@
import path from "node:path";
import { readFileSync } from "node:fs";
import { connectDaemonClient } from "./daemon-client-loader";
import { createTempDirectory, createTempGitRepo } from "./workspace";
/**
* The general-purpose E2E daemon client used to seed and drive state out of
* band (workspaces, agents, terminals) while the UI is exercised through the
* browser. Domain-specific helpers wrap it for their own flows; specs should
* prefer those wrappers over reaching for this client directly.
*/
export interface SeedDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: {
id: string;
name: string;
projectId: string;
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
} | null;
error: string | null;
}>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
cwd: string;
title?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
}>;
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
}
export async function connectSeedClient(): Promise<SeedDaemonClient> {
return connectDaemonClient<SeedDaemonClient>({
clientIdPrefix: "seed",
appVersion: loadAppVersion(),
});
}
/**
* A temp project opened as a workspace, with a seed client connected to drive
* it out of band. `cleanup` closes the client and removes the project. This is
* the canonical bootstrap for specs that need a real workspace plus daemon
* access; domain helpers (e.g. mock-agent) build on it rather than re-rolling
* the trio. `repoPath` is the project root on disk (git repo or plain dir).
*/
export interface SeededWorkspace {
client: SeedDaemonClient;
repoPath: string;
workspaceId: string;
workspaceName: string;
workspaceDirectory: string;
/** Stable project identity the daemon groups workspaces under. */
projectId: string;
/** Project label the UI shows (owner/repo for known remotes, else basename). */
projectDisplayName: string;
cleanup(): Promise<void>;
}
export async function seedWorkspace(options: {
repoPrefix: string;
/** Repo fixture options; only applies to git projects (the default). */
repo?: Parameters<typeof createTempGitRepo>[1];
/** Set to false to seed a plain non-git directory instead of a git repo. */
git?: boolean;
}): Promise<SeededWorkspace> {
const project =
options.git === false
? await createTempDirectory(options.repoPrefix)
: await createTempGitRepo(options.repoPrefix, options.repo);
const client = await connectSeedClient();
try {
const opened = await client.openProject(project.path);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${project.path}`);
}
return {
client,
repoPath: project.path,
workspaceId: opened.workspace.id,
workspaceName: opened.workspace.name,
workspaceDirectory: opened.workspace.workspaceDirectory,
projectId: opened.workspace.projectId,
projectDisplayName: opened.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
},
};
} catch (error) {
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
throw error;
}
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}

View File

@@ -0,0 +1,13 @@
/**
* Resolves the isolated E2E daemon's server id, which Playwright's globalSetup
* publishes into the environment before any spec runs. Helpers and specs that
* build host routes or `sidebar-workspace-row-${serverId}:${id}` selectors share
* this accessor instead of re-reading the env var.
*/
export function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}

View File

@@ -1,12 +1,21 @@
import { expect, type Page } from "@playwright/test";
import { requireServerId } from "./sidebar";
import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const REGISTRY_KEY = "@paseo:daemon-registry";
interface SavedSettingsHostInput {
serverId: string;
label: string;
endpoint: string;
}
const SECTION_LABELS = {
general: "General",
appearance: "Appearance",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
@@ -16,6 +25,8 @@ const SECTION_LABELS = {
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
@@ -31,8 +42,22 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
// Host sections are now flat top-level rows under the Host group. Navigate by
// clicking the Connections section row; the picker only matters when >1 host.
await page.getByTestId("settings-host-section-connections").click();
await expectHostSettingsUrl(page, serverId);
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
}
export async function openSettingsHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await page.getByTestId(`settings-host-section-${section}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
);
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
@@ -40,6 +65,9 @@ export async function expectSettingsHeader(page: Page, title: string): Promise<v
}
export async function openAddHostFlow(page: Page): Promise<void> {
// "Add host" is now an item inside the host picker (a Combobox); open the
// picker first, then pick it. The picker renders whenever a host exists.
await page.getByTestId("settings-host-picker").click();
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
}
@@ -66,12 +94,66 @@ export async function openCompactSettings(page: Page): Promise<void> {
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
export async function seedSavedSettingsHosts(
page: Page,
hosts: SavedSettingsHostInput[],
): Promise<void> {
await page.goto("/");
const nowIso = new Date().toISOString();
const registry = hosts.map((host) =>
buildSeededHost({
serverId: host.serverId,
label: host.label,
endpoint: host.endpoint,
nowIso,
}),
);
const firstHost = registry[0];
if (!firstHost) {
throw new Error("Expected at least one settings host fixture.");
}
const preferences = buildCreateAgentPreferences(firstHost.serverId);
await page.evaluate(
({ keys, storedRegistry, storedPreferences }) => {
const nonce = localStorage.getItem(keys.seedNonce);
if (!nonce) {
throw new Error("Expected e2e seed nonce before overriding settings host registry.");
}
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
},
{
keys: {
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
registry: REGISTRY_KEY,
seedNonce: SEED_NONCE_KEY,
},
storedRegistry: registry,
storedPreferences: preferences,
},
);
}
export async function selectSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId("settings-host-picker").click();
await page.getByTestId(`settings-host-picker-item-${serverId}`).click();
}
export async function expectSettingsHostPickerLabel(page: Page, label: string): Promise<void> {
await expect(
page.getByTestId("settings-host-picker").getByText(label, { exact: true }),
).toBeVisible();
}
export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
await expect(page.getByTestId("host-page-connections-card")).toHaveCount(0);
}
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
@@ -108,18 +190,18 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
);
}
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
const serverId = requireServerId();
const serverId = getServerId();
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
await expectHostSettingsUrl(page, serverId);
}
export async function openCompactSettingsHost(page: Page): Promise<void> {
const serverId = requireServerId();
const serverId = getServerId();
await openSettingsHost(page, serverId);
await expectHostSettingsUrl(page, serverId);
}
@@ -159,16 +241,20 @@ export async function expectDiagnosticsContent(page: Page): Promise<void> {
}
export async function expectAboutContent(page: Page): Promise<void> {
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
await expect(page.getByText("App version", { exact: true }).first()).toBeVisible();
}
export async function expectGeneralContent(page: Page): Promise<void> {
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
}
export async function expectAppearanceContent(page: Page): Promise<void> {
await expect(page.getByText("Highlight theme", { exact: true }).first()).toBeVisible();
}
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveCount(0);
}
export async function clickEditHostLabel(page: Page): Promise<void> {
@@ -176,15 +262,19 @@ export async function clickEditHostLabel(page: Page): Promise<void> {
}
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible();
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel);
await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible();
}
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
const card = page.getByTestId("host-page-connections-card");
await expect(card).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
// "Connections" appears three times on this page: the sidebar section row, the
// detail header title, and the SettingsSection heading above the card. Match
// the first to keep the heading assertion without tripping Playwright strict
// mode.
await expect(page.getByText("Connections", { exact: true }).first()).toBeVisible();
await expect(
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
@@ -196,14 +286,29 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
export async function expectHostActionCards(page: Page): Promise<void> {
export async function openHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await openSettingsHostSection(page, serverId, section);
}
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
// Restart + remove cards live on the Host section; providers moved to its
// own Providers section (asserted via expectHostProvidersCard).
await openSettingsHostSection(page, serverId, "host");
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
}
export async function expectHostProvidersCard(page: Page, serverId: string): Promise<void> {
await openSettingsHostSection(page, serverId, "providers");
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
}
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
await page.route(url, async (route) => {
await route.fulfill({
@@ -247,27 +352,36 @@ export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
// App group rows remain top-level.
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
// The old per-host entry rows are replaced by the host picker.
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
}
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
export async function expectHostPageVisible(page: Page, _serverId: string): Promise<void> {
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
}
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
export async function expectLocalHostEntryFirst(page: Page, _serverId: string): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
// open) that surfaces the local host by its label. The "Local" marker only
// appears on dropdown rows in the multi-host case, which this fixture does not
// exercise.
const picker = sidebar.getByTestId("settings-host-picker");
await expect(picker).toBeVisible();
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
}

View File

@@ -1,15 +1,8 @@
import { expect, type Page } from "@playwright/test";
export function requireServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
import { getServerId } from "./server-id";
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
}

View File

@@ -1,5 +1,6 @@
import { expect, type Page } from "../fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { wsRoutePatternForPort } from "./daemon-port";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
@@ -78,7 +79,7 @@ class StartupScenario {
}
for (const port of this.blockedEndpointPorts) {
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
await this.page.routeWebSocket(wsRoutePatternForPort(port), async (ws) => {
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
});
}
@@ -227,7 +228,3 @@ function buildStoredHost(input: {
function buildStoredCreateAgentPreferences(serverId: string) {
return buildCreateAgentPreferences(serverId);
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -1,11 +1,7 @@
import type { Page } from "@playwright/test";
import { createTempGitRepo } from "./workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./terminal-perf";
import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
interface TempRepo {
path: string;
@@ -19,12 +15,12 @@ export interface TerminalInstance {
}
export class TerminalE2EHarness {
readonly client: TerminalPerfDaemonClient;
readonly client: SeedDaemonClient;
readonly tempRepo: TempRepo;
readonly workspaceId: string;
private constructor(input: {
client: TerminalPerfDaemonClient;
client: SeedDaemonClient;
tempRepo: TempRepo;
workspaceId: string;
}) {
@@ -35,7 +31,7 @@ export class TerminalE2EHarness {
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
const tempRepo = await createTempGitRepo(input.tempPrefix);
const client = await connectTerminalClient();
const client = await connectSeedClient();
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) {
await client.close().catch(() => {});

View File

@@ -1,101 +1,6 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export interface TerminalPerfDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: { id: string; name: string; projectRootPath: string } | null;
error: string | null;
}>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
cwd: string;
title?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
}
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
interface TerminalPerfDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
import { getServerId } from "./server-id";
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
const serverId = getServerId();

View File

@@ -0,0 +1,102 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
interface LongTimelineAgentOptions {
turns: number;
}
interface LongTimelineAgent extends MockAgentWorkspace {
oldestPrompt: string;
newestPrompt: string;
}
const PROMPT_PREFIX = "timeline-pagination-turn";
function promptForTurn(index: number): string {
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
}
export async function seedLongMockAgentTimeline(
options: LongTimelineAgentOptions,
): Promise<LongTimelineAgent> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-pagination-",
title: "Timeline pagination regression",
model: "ten-second-stream",
});
for (let index = 0; index < options.turns; index += 1) {
await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index));
await agent.client.waitForFinish(agent.agentId, 15_000);
}
return {
...agent,
oldestPrompt: promptForTurn(0),
newestPrompt: promptForTurn(options.turns - 1),
};
}
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
await page.goto(buildAgentRoute(agent.cwd, agent.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
}
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
}
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
}
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();
await page.mouse.wheel(0, -20_000);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
await scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
}
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const previousHeight = await scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
});
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
await scrollTimelineToOldestLoadedEdge(page);
await expect
.poll(async () =>
scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
}),
)
.toBeGreaterThan(previousHeight);
await scrollTimelineToOldestLoadedEdge(page);
}

View File

@@ -1,10 +1,9 @@
import { execSync } from "node:child_process";
import { realpath } from "node:fs/promises";
import path from "node:path";
import type { Page } from "@playwright/test";
import { waitForTabBar } from "./launcher";
import { selectWorkspaceInSidebar } from "./sidebar";
import { createTempGitRepo } from "./workspace";
import { createTempGitRepo, resolveTempRoot } from "./workspace";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
@@ -49,7 +48,7 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
let workspacePath = repo.path;
if (options?.worktree) {
const tempRoot = await realpath("/tmp");
const tempRoot = await resolveTempRoot();
workspacePath = path.join(
tempRoot,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,

View File

@@ -1,59 +1,25 @@
import { realpathSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
import { gotoAppShell } from "./app";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { connectDaemonClient } from "./daemon-client-loader";
import { getServerId } from "./server-id";
import { switchWorkspaceViaSidebar } from "./workspace-ui";
import type { SessionOutboundMessage } from "@server/shared/messages";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
interface WorkspaceSetupDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: {
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
workspace: {
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
fetchWorkspaces(): Promise<{
entries: Array<{
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
}>;
}>;
fetchAgents(): Promise<{
entries: Array<{
agent: { id: string; cwd: string; workspaceId?: string | null };
}>;
}>;
fetchAgent(agentId: string): Promise<{
agent: { id: string; cwd: string } | null;
project: unknown;
} | null>;
listTerminals(cwd: string): Promise<{
cwd?: string;
terminals: Array<{ id: string; cwd: string; name: string }>;
error?: string | null;
}>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
}
type WorkspaceSetupDaemonClient = Pick<
InternalDaemonClient,
| "close"
| "connect"
| "createPaseoWorktree"
| "fetchAgent"
| "fetchAgents"
| "fetchWorkspaces"
| "listTerminals"
| "openProject"
| "subscribeRawMessages"
>;
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
@@ -62,58 +28,30 @@ export type WorkspaceSetupProgressPayload = Extract<
export type { WorkspaceSetupDaemonClient };
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}) => WorkspaceSetupDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}) => WorkspaceSetupDaemonClient;
};
return mod.DaemonClient;
}
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `workspace-setup-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
return connectDaemonClient<WorkspaceSetupDaemonClient>({ clientIdPrefix: "workspace-setup" });
}
export async function openProjectViaDaemon(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
const result = await client.openProject(repoPath);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
}
return {
id: result.workspace.id,
name: result.workspace.name,
workspaceDirectory: result.workspace.workspaceDirectory,
};
}
export async function seedProjectForWorkspaceSetup(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<void> {
const result = await client.openProject(repoPath);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
}
await openProjectViaDaemon(client, repoPath);
}
export function projectNameFromPath(repoPath: string): string {
@@ -320,11 +258,11 @@ export async function navigateToWorkspaceViaSidebar(
page: Page,
workspaceId: string,
): Promise<void> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId });
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
targetWorkspacePath: workspaceId,
});
}
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {

View File

@@ -1,6 +1,7 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
import { escapeRegex } from "./regex";
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
@@ -23,10 +24,6 @@ export function workspaceLabelFromPath(value: string): string {
return parts[parts.length - 1] ?? normalized;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, "");
const candidates = new Set<string>([trimmed]);

View File

@@ -9,6 +9,20 @@ interface TempRepo {
cleanup: () => Promise<void>;
}
export interface TempDirectory {
path: string;
cleanup: () => Promise<void>;
}
/**
* The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp →
* /private/tmp) so fixture paths match the daemon's resolved paths; on Windows
* `/tmp` doesn't exist, so fall back to the OS temp dir.
*/
export async function resolveTempRoot(): Promise<string> {
return process.platform === "win32" ? tmpdir() : await realpath("/tmp");
}
async function configureRemote(input: {
repoPath: string;
withRemote: boolean;
@@ -22,6 +36,15 @@ async function configureRemote(input: {
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
if (originUrl) {
// Relabel origin to a display URL after the local tracking remote is set
// up, so project grouping shows the remote's owner/repo while branch
// tracking refs still resolve locally (no fetch from the synthetic URL).
execSync(`git remote set-url origin ${JSON.stringify(originUrl)}`, {
cwd: repoPath,
stdio: "ignore",
});
}
return;
}
if (originUrl) {
@@ -44,9 +67,7 @@ export const createTempGitRepo = async (
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const repoPath = await mkdtemp(path.join(await resolveTempRoot(), prefix));
const withRemote = options?.withRemote ?? false;
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
@@ -110,6 +131,21 @@ export const createTempGitRepo = async (
};
};
/**
* A plain (non-git) directory opened as a project. The daemon shows its
* basename as the project name, since there's no remote to group under.
*/
export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<TempDirectory> {
const dirPath = await mkdtemp(path.join(await resolveTempRoot(), prefix));
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
return {
path: dirPath,
cleanup: async () => {
await rm(dirPath, { recursive: true, force: true });
},
};
}
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
currentBranch: string;
hasAncestor: (ref: string) => boolean;

View File

@@ -1,5 +1,4 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
gotoWorkspace,
assertNewChatTileVisible,
@@ -16,30 +15,19 @@ import {
terminalSurfaceLocator,
} from "./helpers/launcher";
import { expectComposerVisible, composerLocator } from "./helpers/composer";
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
import {
connectTerminalClient,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./helpers/terminal-perf";
import { expectTerminalSurfaceVisible, setupDeterministicPrompt } from "./helpers/terminal-perf";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
// ─── Shared state ──────────────────────────────────────────────────────────
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: TerminalPerfDaemonClient;
let workspace: SeededWorkspace;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("launcher-e2e-");
seedClient = await connectTerminalClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
workspaceId = result.workspace.id;
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
});
test.afterAll(async () => {
if (seedClient) await seedClient.close();
if (tempRepo) await tempRepo.cleanup();
await workspace?.cleanup();
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -48,7 +36,7 @@ test.afterAll(async () => {
test.describe("Tab creation", () => {
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await pressNewTabShortcut(page);
@@ -56,7 +44,7 @@ test.describe("Tab creation", () => {
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
@@ -75,7 +63,7 @@ test.describe("Tab creation", () => {
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
@@ -88,7 +76,7 @@ test.describe("Tab creation", () => {
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
@@ -100,7 +88,7 @@ test.describe("Tab creation", () => {
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page);
await assertTerminalTileVisible(page);
@@ -117,26 +105,16 @@ test.describe("Terminal title propagation", () => {
// must re-render before the assertion deadline. Allow retries.
test.describe.configure({ retries: 2 });
let client: TerminalPerfDaemonClient;
test.beforeAll(async () => {
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) await client.close();
});
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "title-test");
const result = await workspace.client.createTerminal(workspace.repoPath, "title-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
// Navigate to workspace and open a terminal
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
@@ -153,19 +131,19 @@ test.describe("Terminal title propagation", () => {
// Wait for the tab to reflect the new title
await waitForTabWithTitle(page, testTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
await workspace.client.killTerminal(terminalId).catch(() => {});
}
});
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "debounce-test");
const result = await workspace.client.createTerminal(workspace.repoPath, "debounce-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
@@ -188,7 +166,7 @@ test.describe("Terminal title propagation", () => {
// The tab should eventually settle on the final title
await waitForTabWithTitle(page, finalTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
await workspace.client.killTerminal(terminalId).catch(() => {});
}
});
});
@@ -199,7 +177,7 @@ test.describe("Terminal title propagation", () => {
test.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
@@ -221,7 +199,7 @@ test.describe("Tab transitions (no flash)", () => {
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
@@ -237,7 +215,7 @@ test.describe("Tab transitions (no flash)", () => {
});
test("New agent tab click shows composer without flash", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,

View File

@@ -7,16 +7,17 @@ import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
clickNewWorkspaceButton,
closeBranchPicker,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
delayBrowserAgentCreatedStatus,
expectComposerGithubAttachmentPill,
expectNewWorkspaceProjectSelected,
expectPickerClosed,
expectPickerOpen,
expectPickerSelected,
expectStartingRefPickerTriggerPr,
openGlobalNewWorkspaceComposer,
openBranchPicker,
openNewWorkspaceComposer,
openProjectViaDaemon,
@@ -24,8 +25,10 @@ import {
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import { getServerId } from "./helpers/server-id";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
@@ -35,6 +38,147 @@ import {
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
interface WorkspaceStatusGroupEvent {
rowTestId: string;
bucket: string;
indicatorTestId: string | null;
label: string;
at: number;
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
}
async function startTrackingSidebarStatusGroups(page: import("@playwright/test").Page) {
await page.evaluate(() => {
interface StatusGroupEvent {
rowTestId: string;
bucket: string;
indicatorTestId: string | null;
label: string;
at: number;
}
const win = window as typeof window & {
__workspaceStatusGroupEvents?: StatusGroupEvent[];
__workspaceStatusGroupObserver?: MutationObserver;
};
win.__workspaceStatusGroupEvents = [];
win.__workspaceStatusGroupObserver?.disconnect();
const capture = () => {
const events = win.__workspaceStatusGroupEvents;
if (!events) return;
const groups = document.querySelectorAll<HTMLElement>(
'[data-testid^="sidebar-status-group-"]',
);
for (const group of groups) {
const groupTestId = group.getAttribute("data-testid") ?? "";
const bucket = groupTestId.replace("sidebar-status-group-", "");
const label = group.textContent ?? "";
const block = group.parentElement?.parentElement;
if (!block) continue;
const rows = block.querySelectorAll<HTMLElement>('[data-testid^="sidebar-workspace-row-"]');
for (const row of rows) {
const rowTestId = row.getAttribute("data-testid");
if (!rowTestId) continue;
const indicatorTestId =
row
.querySelector<HTMLElement>('[data-testid^="workspace-status-indicator-"]')
?.getAttribute("data-testid") ?? null;
const last = events.at(-1);
if (
last?.rowTestId === rowTestId &&
last.bucket === bucket &&
last.indicatorTestId === indicatorTestId
) {
continue;
}
events.push({ rowTestId, bucket, indicatorTestId, label, at: performance.now() });
}
}
};
capture();
const observer = new MutationObserver(capture);
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
win.__workspaceStatusGroupObserver = observer;
});
}
async function getTrackedSidebarStatusGroups(
page: import("@playwright/test").Page,
): Promise<WorkspaceStatusGroupEvent[]> {
return page.evaluate(() => {
const win = window as typeof window & {
__workspaceStatusGroupEvents?: WorkspaceStatusGroupEvent[];
};
return win.__workspaceStatusGroupEvents ?? [];
});
}
async function waitForWorkspaceStatusGroupEvent(input: {
page: import("@playwright/test").Page;
rowTestId: string;
bucket: string;
}) {
await input.page.waitForFunction(
({ expectedRowTestId, expectedBucket }) => {
const win = window as typeof window & {
__workspaceStatusGroupEvents?: WorkspaceStatusGroupEvent[];
};
for (const event of win.__workspaceStatusGroupEvents ?? []) {
if (event.rowTestId === expectedRowTestId && event.bucket === expectedBucket) {
return true;
}
}
return false;
},
{ expectedRowTestId: input.rowTestId, expectedBucket: input.bucket },
{ timeout: 30_000 },
);
}
async function expectWorkspaceStatusGroupEvents(input: {
page: import("@playwright/test").Page;
rowTestId: string;
includes: string;
excludes: string;
includesIndicator?: string;
excludesIndicator?: string;
}) {
await waitForWorkspaceStatusGroupEvent({
page: input.page,
rowTestId: input.rowTestId,
bucket: input.includes,
});
const createdWorkspaceEvents = (await getTrackedSidebarStatusGroups(input.page)).filter(
(event) => event.rowTestId === input.rowTestId,
);
expect(createdWorkspaceEvents.map((event) => event.bucket)).toContain(input.includes);
expect(createdWorkspaceEvents.filter((event) => event.bucket === input.excludes)).toEqual([]);
if (input.includesIndicator) {
expect(createdWorkspaceEvents.map((event) => event.indicatorTestId)).toContain(
input.includesIndicator,
);
}
if (input.excludesIndicator) {
expect(
createdWorkspaceEvents.filter((event) => event.indicatorTestId === input.excludesIndicator),
).toEqual([]);
}
}
async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").Page) {
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
}
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
@@ -61,10 +205,7 @@ test.describe("New workspace flow", () => {
});
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const firstRepo = await createTempGitRepo("workspace-nav-a-");
const secondRepo = await createTempGitRepo("workspace-nav-b-");
@@ -118,10 +259,7 @@ test.describe("New workspace flow", () => {
});
test("same-project workspaces switch content without requiring refresh", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const repo = await createTempGitRepo("workspace-nav-same-project-");
@@ -198,13 +336,10 @@ test.describe("New workspace flow", () => {
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
test("global new workspace uses the last active project and creates one agent tab", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-");
@@ -225,15 +360,16 @@ test.describe("New workspace flow", () => {
subtitle: openedProject.projectDisplayName,
});
await clickNewWorkspaceButton(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
await submitNewWorkspacePrompt(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
@@ -276,10 +412,7 @@ test.describe("New workspace flow", () => {
});
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
@@ -323,6 +456,8 @@ test.describe("New workspace flow", () => {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
@@ -354,11 +489,119 @@ test.describe("New workspace flow", () => {
}
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
test("new workspace with initial agent never appears in the Done status group", async ({
page,
}) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-status-optimistic-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await switchSidebarToStatusGrouping(page);
await startTrackingSidebarStatusGroups(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
await submitNewWorkspacePrompt(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({
page,
rowTestId,
includes: "running",
excludes: "done",
includesIndicator: "workspace-status-indicator-running",
});
} finally {
await tempRepo.cleanup();
}
});
test("new workspace without an initial agent appears in the Done status group", async ({
page,
}) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-status-empty-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await switchSidebarToStatusGrouping(page);
await startTrackingSidebarStatusGroups(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
await submitNewWorkspaceWithoutPrompt(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({
page,
rowTestId,
includes: "done",
excludes: "running",
excludesIndicator: "workspace-status-indicator-loading",
});
await expectWorkspaceStatusGroupEvents({
page,
rowTestId,
includes: "done",
excludes: "running",
excludesIndicator: "workspace-status-indicator-running",
});
} finally {
await tempRepo.cleanup();
}
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
branches: ["main", "dev"],
@@ -414,7 +657,7 @@ test.describe("New workspace flow", () => {
}
});
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
page,
}) => {
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });

View File

@@ -10,6 +10,7 @@ import { gotoWorkspace } from "./helpers/launcher";
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
import {
connectWorkspaceSetupClient,
openProjectViaDaemon,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
@@ -50,11 +51,8 @@ test.describe("PR pane", () => {
});
for (const pr of repoFixture.prs) {
const result = await seedClient.openProject(pr.localPath);
if (!result.workspace) {
throw new Error(result.error ?? `Failed to open project ${pr.localPath}`);
}
workspaceByTitle.set(pr.title, result.workspace.id);
const workspace = await openProjectViaDaemon(seedClient, pr.localPath);
workspaceByTitle.set(pr.title, workspace.id);
}
});

View File

@@ -1,8 +1,7 @@
import { chmod, readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base } from "./fixtures";
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import { seedWorkspace } from "./helpers/seed-client";
import {
blockPaseoConfigWrites,
bumpPaseoConfigOnDisk,
@@ -63,38 +62,36 @@ const initialPaseoConfig = {
const test = base.extend<ProjectsSettingsFixtures>({
editableProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-", {
paseoConfig: initialPaseoConfig,
const workspace = await seedWorkspace({
repoPrefix: "projects-settings-",
repo: { paseoConfig: initialPaseoConfig },
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
name: workspace.projectDisplayName,
path: workspace.repoPath,
});
await client.close();
// Defensive: restore directory write permission in case the test left it blocked
// (write_failed test), so that repo.cleanup() can remove files inside.
await chmod(repo.path, 0o755).catch(() => undefined);
await repo.cleanup();
// (write_failed test), so that cleanup can remove files inside.
await chmod(workspace.repoPath, 0o755).catch(() => undefined);
await workspace.cleanup();
},
gitlabRemoteProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-gitlab-", {
paseoConfig: initialPaseoConfig,
originUrl: "https://gitlab.com/acme/app.git",
const workspace = await seedWorkspace({
repoPrefix: "projects-settings-gitlab-",
repo: {
paseoConfig: initialPaseoConfig,
originUrl: "https://gitlab.com/acme/app.git",
},
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
name: workspace.projectDisplayName,
path: workspace.repoPath,
});
await client.close();
await repo.cleanup();
await workspace.cleanup();
},
});

View File

@@ -0,0 +1,73 @@
import { test } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
chooseQuestionOption,
expectCurrentQuestion,
expectQuestionHidden,
expectQuestionOptionSelected,
fillQuestionAnswer,
openQuestion,
submitQuestionAnswers,
waitForQuestionPrompt,
} from "./helpers/questions";
const TOTAL_QUESTIONS = 3;
const SURFACE_QUESTION = "Which surface should this apply to?";
const ROLLOUT_QUESTION = "Which rollout should we use?";
const SUCCESS_QUESTION = "What success criteria should we use?";
test.describe("Question prompt pagination", () => {
test("shows one question at a time with numbered navigation", async ({ page }) => {
test.setTimeout(180_000);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-pagination-",
title: "Question pagination e2e",
initialPrompt: "Emit synthetic questions.",
});
try {
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
await expectCurrentQuestion(page, {
index: 1,
total: TOTAL_QUESTIONS,
question: SURFACE_QUESTION,
});
await expectQuestionHidden(page, ROLLOUT_QUESTION);
await expectQuestionHidden(page, SUCCESS_QUESTION);
await chooseQuestionOption(page, "App");
await expectCurrentQuestion(page, {
index: 2,
total: TOTAL_QUESTIONS,
question: ROLLOUT_QUESTION,
});
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectCurrentQuestion(page, {
index: 1,
total: TOTAL_QUESTIONS,
question: SURFACE_QUESTION,
});
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await chooseQuestionOption(page, "Behind feature flag");
await expectCurrentQuestion(page, {
index: 3,
total: TOTAL_QUESTIONS,
question: SUCCESS_QUESTION,
});
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Only one prompt is visible at a time.",
});
await submitQuestionAnswers(page);
} finally {
await session.cleanup();
}
});
});

View File

@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "claude",
rewindMode: "both",
fileReverted: true,
});

View File

@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "codex",
rewindMode: "conversation",
fileReverted: false,
});

View File

@@ -0,0 +1,8 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "opencode",
initialRewindMode: "both",
rewindMode: "both",
fileReverted: true,
});

View File

@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "pi",
rewindMode: "conversation",
fileReverted: false,
});

View File

@@ -0,0 +1,88 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "./fixtures";
import {
assertChatTranscript,
assertComposerIdle,
assertFileContains,
assertFileExists,
assertFileMissing,
cleanupRewindFlow,
launchAgent,
rewindMessage,
sendMessage,
type AgentHandle,
type RewindFlowMode,
type RewindFlowProvider,
} from "./helpers/rewind-flow";
const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN";
interface RewindFlowCase {
provider: RewindFlowProvider;
initialRewindMode?: RewindFlowMode;
rewindMode: RewindFlowMode;
fileReverted: boolean;
}
export function defineRewindFlowSpec(input: RewindFlowCase): void {
test.describe(`rewind flow - ${input.provider}`, () => {
test.setTimeout(600_000);
test("rewinds conversation and file-write turns without transcript drift", async ({ page }) => {
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-rewind-flow-${input.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: input.provider,
cwd,
mode: "full-access",
});
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 0, input.initialRewindMode ?? "conversation");
await assertChatTranscript(handle, []);
await assertComposerIdle(handle);
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await sendMessage(handle, FILE_PROMPT);
await assertFileContains(path.join(cwd, "qa.txt"), "PASEO_QA_TOKEN");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
{ role: "user", text: /Use the Write tool/ },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 1, input.rewindMode);
if (input.fileReverted) {
await assertFileMissing(path.join(cwd, "qa.txt"));
} else {
await assertFileExists(path.join(cwd, "qa.txt"));
}
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await assertComposerIdle(handle);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
}

View File

@@ -0,0 +1,132 @@
import { expect, test, type Page } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "./helpers/composer";
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected);
}
test.describe("Rewind sheet", () => {
test("rewinds from a user message sheet option", async ({ page }) => {
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-e2e-",
title: "Rewind e2e",
initialPrompt: firstPrompt,
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await expectUserMessageCount(page, 1);
await submitMessage(page, secondPrompt);
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 1);
await expectComposerDraft(page, firstPrompt);
await submitMessage(page, replacementPrompt);
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, "");
await expectUserMessageCount(page, 2);
const preservedDraft = "Keep this human draft after rewind.";
await fillComposerDraft(page, preservedDraft);
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, preservedDraft);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-both").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, replacementPrompt);
await expectUserMessageCount(page, 1);
} finally {
await session.cleanup();
}
});
test("surfaces rewind failures without crashing the page", async ({ page }) => {
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-failure-e2e-",
title: "Rewind failure e2e",
initialPrompt: firstPrompt,
featureValues: {
mockRewindError: rewindError,
},
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
} finally {
await session.cleanup();
}
});
});

View File

@@ -1,103 +1,125 @@
import { test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
import { getServerId } from "./helpers/server-id";
import {
expectSettingsHeader,
openSettingsHost,
openHostSection,
expectHostLabelDisplayed,
clickEditHostLabel,
expectHostLabelEditMode,
expectHostConnectionsCard,
expectHostInjectMcpCard,
expectHostActionCards,
expectHostProvidersCard,
expectHostNoLocalOnlyRows,
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
expectLocalHostEntryFirst,
} from "./helpers/settings";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
function getSeededDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
return port;
}
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
}) => {
const serverId = getSeededServerId();
const port = getSeededDaemonPort();
test("connections section shows the seeded connection endpoint", async ({ page }) => {
const serverId = getServerId();
const port = getE2EDaemonPort();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectSettingsHeader(page, "Connections");
await expectHostConnectionsCard(page, port);
});
test("agents section shows the inject MCP toggle", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
await expectSettingsHeader(page, "Agents");
await expectHostInjectMcpCard(page);
await expectHostActionCards(page);
});
test("providers section shows the providers card", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await expectHostProvidersCard(page, serverId);
await expectSettingsHeader(page, "Providers");
});
test("host section shows the host label and restart/remove action cards", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
await expectSettingsHeader(page, "Host");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
await expectHostLabelDisplayed(page);
await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
});
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
page,
}) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page);
});
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await expectRetiredSidebarSectionsAbsent(page);
});
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await gotoAppShell(page);
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expectHostPageVisible(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectSettingsHeader(page, "Connections");
await openHostSection(page, serverId, "host");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page);
await expectHostActionCards(page, serverId);
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
// seeded host to the local daemon. `manageBuiltInDaemon: false` (returned

View File

@@ -1,5 +1,6 @@
import { test, expect } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
openSettingsSection,
expectSettingsHeader,
@@ -25,7 +26,13 @@ import {
expectDiagnosticsContent,
expectAboutContent,
expectGeneralContent,
expectAppearanceContent,
seedSavedSettingsHosts,
selectSettingsHost,
expectSettingsHostPickerLabel,
openSettingsHostSection,
} from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
test.describe("Settings sidebar navigation", () => {
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
@@ -43,9 +50,13 @@ test.describe("Settings sidebar navigation", () => {
await openSettingsSection(page, "general");
await expectSettingsHeader(page, "General");
await expectGeneralContent(page);
await openSettingsSection(page, "appearance");
await expectSettingsHeader(page, "Appearance");
await expectAppearanceContent(page);
});
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
});
@@ -132,7 +143,9 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsBackButton(page);
});
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({
page,
}) => {
await gotoAppShell(page);
await openCompactSettings(page);
@@ -150,4 +163,28 @@ test.describe("Settings — compact master-detail", () => {
await expect(page).toHaveURL(/\/settings$/);
await expectSettingsSidebarVisible(page);
});
test("switching the host picker on the settings list scopes host rows without navigating", async ({
page,
}) => {
const primaryServerId = getServerId();
const secondaryServerId = "srv_e2e_settings_secondary";
const secondaryHostLabel = "Stable horse";
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
await seedSavedSettingsHosts(page, [
{ serverId: primaryServerId, label: "First horse", endpoint },
{ serverId: secondaryServerId, label: secondaryHostLabel, endpoint },
]);
await gotoAppShell(page);
await openCompactSettings(page);
await selectSettingsHost(page, secondaryServerId);
await expect(page).toHaveURL(/\/settings$/);
await expectSettingsSidebarVisible(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
});
});

View File

@@ -1,21 +1,9 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import { createIdleAgent, openWorkspaceWithAgents } from "./helpers/archive-tab";
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
@@ -61,20 +49,17 @@ test.describe("Settings toggle tab regression", () => {
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("settings-toggle-tab-");
const agentIds: string[] = [];
const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" });
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `settings-toggle-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
@@ -89,7 +74,7 @@ test.describe("Settings toggle tab regression", () => {
await expectSendBehavior(page, "interrupt");
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath));
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
@@ -97,11 +82,7 @@ test.describe("Settings toggle tab regression", () => {
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
await workspace.cleanup();
}
});
@@ -109,31 +90,28 @@ test.describe("Settings toggle tab regression", () => {
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("agent-route-refresh-");
const agentIds: string[] = [];
const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" });
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `agent-route-refresh-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
workspaceId: workspace.repoPath,
agentId: firstAgent.id,
});
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
workspaceId: workspace.repoPath,
agentId: secondAgent.id,
});
@@ -143,11 +121,7 @@ test.describe("Settings toggle tab regression", () => {
await expectAgentTabActive(page, secondAgent.id);
}
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
await workspace.cleanup();
}
});
});

View File

@@ -0,0 +1,115 @@
import { execSync } from "node:child_process";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { seedWorkspace } from "./helpers/seed-client";
import { captureWsSessionFrames } from "./helpers/rename";
import { getServerId } from "./helpers/server-id";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
function workspaceRenameModalTestId(workspaceId: string, suffix: string): string {
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
}
async function openRenameModal(page: Page, workspaceId: string) {
const serverId = getServerId();
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
await expect(input).toBeVisible({ timeout: 10_000 });
return input;
}
test.describe("Sidebar workspace rename", () => {
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
try {
expect(workspace.workspaceName).toBe("main");
const renameRequests = captureWsSessionFrames(
page,
"checkout.rename_branch.request",
(inner) => ({
branch: String(inner.branch ?? ""),
cwd: String(inner.cwd ?? ""),
}),
);
await gotoAppShell(page);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
const input = await openRenameModal(page, workspace.workspaceId);
await expect(input).toHaveValue("main");
await input.fill("Feature Rename 2");
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
"feature-rename-2",
{ timeout: 15_000 },
);
expect(renameRequests.length).toBeGreaterThan(0);
expect(renameRequests.at(-1)).toEqual({
branch: "feature-rename-2",
cwd: workspace.workspaceDirectory,
});
const currentBranchOnDisk = execSync("git branch --show-current", {
cwd: workspace.repoPath,
stdio: "pipe",
})
.toString()
.trim();
expect(currentBranchOnDisk).toBe("feature-rename-2");
} finally {
await workspace.cleanup();
}
});
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-rename-error-",
repo: { branches: ["taken"] },
});
try {
await gotoAppShell(page);
const input = await openRenameModal(page, workspace.workspaceId);
await expect(input).toHaveValue("main");
await input.fill("taken");
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
const errorNode = page.getByTestId(
workspaceRenameModalTestId(workspace.workspaceId, "error"),
);
await expect(errorNode).toBeVisible({ timeout: 15_000 });
await expect(errorNode).toContainText(/already exists|branch/i);
await expect(input).toBeVisible();
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
"main",
);
} finally {
await workspace.cleanup();
}
});
});

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