Compare commits

...

464 Commits

Author SHA1 Message Date
Mohamed Boudra
84478a2dba chore(release): cut 0.1.68 2026-05-05 16:46:10 +07:00
Mohamed Boudra
93065b20f7 Add changelog for 0.1.68 2026-05-05 16:45:16 +07:00
Mohamed Boudra
bb8762e122 Fix desktop settings first-launch race 2026-05-05 16:43:38 +07:00
Mohamed Boudra
15a2e3bdcb chore(release): cut 0.1.67 2026-05-04 01:43:24 +07:00
Mohamed Boudra
64ba05cea5 Add v0.1.67 changelog entry 2026-05-04 01:42:23 +07:00
Mohamed Boudra
1521ceb381 Surface desktop daemon gate failures instead of silent no-op
The async settings gate added in f0d96f8e called
shouldStartDaemon() inside Promise.resolve(...).then(...) with no
catch. If loadDesktopSettings() rejects (corrupted JSON, FS error),
the daemon never starts, no error renders on the splash, and the
retry button repeats the same silent path.

Add a recordError method to DaemonStartService and an onGateError
callback to startDaemonIfGateAllows / startHostRuntimeBootstrap.
_layout.tsx wires it through so a gate rejection populates
lastError, the splash shows the failure, and retry has signal.
2026-05-04 01:40:47 +07:00
Mohamed Boudra
4338f5b46c Fix workspace reconnect playwright tests after toast move
Test #1 was asserting agent-reconnecting-toast on a workspace with no
agents — but commit 1daa1314 moved the reconnect indicator from the
workspace banner into the per-agent panel toast. Create an idle agent
and navigate to it so the agent panel mounts before dropping the
daemon gate.

Test #2 regex still pinned the old "Connecting to localhost" copy;
commit c534c857 tightened it to just "Connecting" with the hostname in
the description. Match the new title plus the existing offline copies.
2026-05-04 01:18:37 +07:00
Mohamed Boudra
e8a64fb569 ci: build server before desktop tests
f0d96f8e added packages/desktop/src/daemon/daemon-manager.test.ts,
which calls vi.mock("@getpaseo/server", ...). Vite's module
resolution still needs to find the package's main entry, so
desktop CI must build the server (and its dependency chain) before
running the tests. Mirrors the prebuild steps used by typecheck,
server-tests, and playwright.
2026-05-04 00:40:38 +07:00
Mohamed Boudra
cf1f849b68 Update MCP create_agent test to match async branch auto-name
7c85341a moved first-agent branch auto-naming out of the worktree
service (sync slugify) into the workflow layer (async LLM call).
The MCP test stubs createPaseoWorktree, bypassing the workflow,
so the placeholder mnemonic branch is never renamed at this layer.
Replace the obsolete sync-slugify outcome assertion with a
placeholder-presence check; the auto-name logic itself is covered
in worktree-branch-name-generator.test.ts.
2026-05-04 00:31:33 +07:00
Mohamed Boudra
bc0886ad7c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-04 00:16:08 +07:00
Mohamed Boudra
12fcfe53eb Make structured response generation ephemeral end-to-end
Plumbs persistSession through the structured response pipeline so
metadata, branch name, commit message, and PR text generators no
longer leave provider-side artifacts behind:

- Codex: passes ephemeral: true on thread/start (no rollout file)
- OpenCode: deletes the session via session.delete on close
- Claude: forwards persistSession: false to the SDK query

Adds a real-credentials e2e test per provider that asserts the
generated title is applied AND no persistence artifact is produced
(rollout for Codex, project JSONL for Claude, leftover session for
OpenCode).
2026-05-04 00:10:50 +07:00
github-actions[bot]
a198a33ff5 fix: update lockfile signatures and Nix hash 2026-05-03 16:24:36 +00:00
Mohamed Boudra
7c85341a42 Fix worktree creation and archive flow
Server: drop the synchronous branch-name generator dep, use the
worktree slug as the initial branch, and move first-agent branch
auto-naming to an async post-creation step backed by a structured
LLM call. Guards against renaming when the placeholder branch has
already been changed.

App: make workspace and worktree archive optimistic, rolling back
the local hide if the daemon call fails, and suppress incoming
workspace upserts while an archive is pending.
2026-05-03 23:22:54 +07:00
Mohamed Boudra
c534c857a4 Tighten workspace host connecting copy 2026-05-03 23:01:29 +07:00
Mohamed Boudra
6d8c7c4d2d Fix desktop settings legacy override 2026-05-03 22:57:33 +07:00
Mohamed Boudra
f0d96f8e5a Fix built-in daemon management toggle 2026-05-03 22:02:40 +07:00
Mohamed Boudra
f8d4758e6b Run iOS Fastlane review-submit on macOS
Linux EAS runners don't ship Ruby/Bundler, so `bundle install`
fails with "bundle: command not found". macOS workflow runners
have Ruby 3.2 + Fastlane preinstalled, so switching this job to
runs_on: macos-medium gets us there without a manual install step.
2026-05-03 21:27:24 +07:00
github-actions[bot]
09b498da61 fix: update lockfile signatures and Nix hash 2026-05-03 13:50:32 +00:00
Mohamed Boudra
221b6665a0 chore(release): cut 0.1.66 2026-05-03 20:48:53 +07:00
Mohamed Boudra
94dd36a4c9 Fix EAS Fastlane working_directory doubling
The eas/checkout step already cd's into packages/app (where eas.json
lives), so the explicit `working_directory: ./packages/app` on the
Fastlane steps resolved to /home/expo/workingdir/build/packages/app/packages/app
and failed every release with ENOENT.
2026-05-03 20:44:01 +07:00
Mohamed Boudra
84c1ff560f Add 0.1.66 changelog entry 2026-05-03 20:39:26 +07:00
Mohamed Boudra
b7f9092e93 Fix terminal renderer remount loading 2026-05-03 20:38:12 +07:00
Mohamed Boudra
ade05607d2 fix(app): latch startup store readiness 2026-05-03 20:15:19 +07:00
Mohamed Boudra
6d13796b2d Move workspace gate background and root layout off useUnistyles
The workspace gate (loading/unreachable/missing) used surfaceWorkspace,
which clashed with the panels' surface0. Switch the gate shell to
surface0 so it matches the agent panel.

Also remove three hot-path useUnistyles() calls from _layout.tsx that
were re-rendering the whole app on every theme/breakpoint/insets change:
RootLayout and AppContainer move to StyleSheet.create, and the desktop
window-controls effect lifts into a small leaf component.
2026-05-03 19:49:10 +07:00
Mohamed Boudra
191cea47a0 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-03 19:25:23 +07:00
Mohamed Boudra
0aa4868b31 Fix terminal DSR replies 2026-05-03 19:24:30 +07:00
github-actions[bot]
e7016ef6b8 fix: update lockfile signatures and Nix hash 2026-05-03 12:19:32 +00:00
Mohamed Boudra
4332eca9ca Shorten agent initialization timeout 2026-05-03 19:12:36 +07:00
Mohamed Boudra
fc31d31493 Disable dev client runtime metrics logging 2026-05-03 19:11:25 +07:00
Mohamed Boudra
8ba71cc189 Preserve live markdown block newlines 2026-05-03 19:09:24 +07:00
Mohamed Boudra
38c1b08433 chore(release): cut 0.1.65 2026-05-03 17:20:28 +07:00
Mohamed Boudra
21a5d4f061 Promote 0.1.65 changelog heading to stable 2026-05-03 17:19:34 +07:00
Mohamed Boudra
1daa131480 Replace workspace reconnect banner with persistent agent panel toast
The workspace reconnect banner ignored safe-area insets on Android,
overlapping the system status bar. The agent panel already shows a
"Reconnecting..." toast on disconnect, so consolidate on that and drop
the banner entirely. Add persistent toast support (durationMs: null)
and dismiss the toast when the connection returns to online instead of
auto-dismissing after 2.2s.
2026-05-03 17:17:02 +07:00
Mohamed Boudra
f33a5191df Drop obsolete Connecting… assertion from reconnect welcome DSL
The 'Connecting…' placeholder was removed from welcome-screen.tsx in
02adadbb (Always show welcome copy on welcome screen) but the e2e
helper still asserted it visible, breaking startup-loading.spec.ts.
2026-05-03 15:42:57 +07:00
Mohamed Boudra
d6c3896fd9 Update test mocks for new serviceUrlBehavior setting
f58cfe9 (Open script service URLs in-app or external) added
serviceUrlBehavior to AppSettings and a new useDropdownMenuClose
export from dropdown-menu. The unit tests were not updated:

- use-settings.test.ts: expected settings objects missed the new
  serviceUrlBehavior field.
- workspace-scripts-button.test.tsx: dropdown-menu mock missed
  useDropdownMenuClose, breaking HostLinkRow render.
2026-05-03 15:38:01 +07:00
Mohamed Boudra
f2da4b861b Fix sidebar workspace list test AsyncStorage mock
fa20f313 wired AsyncStorage persistence into
activateNavigationWorkspaceSelection. The mock here returned undefined
from setItem, so the .catch() in persistLastWorkspaceRouteSelection
threw. Make get/set/removeItem return resolved promises like real
AsyncStorage.
2026-05-03 15:25:04 +07:00
Mohamed Boudra
eec9804567 Tighten 0.1.65-beta.5 changelog and document unreleased-fix trap 2026-05-03 15:20:35 +07:00
Mohamed Boudra
f58cfe9008 Open script service URLs in-app or external
Adds a Service URLs setting (ask / in-Paseo / external browser) that
controls where URLs from running scripts open on desktop. First click
prompts with a "don't ask again" checkbox; in-Paseo opens a workspace
browser tab. Closing a browser tab now clears its session partition.
2026-05-03 15:14:28 +07:00
Mohamed Boudra
02adadbbc8 Always show welcome copy on welcome screen
Drop the "Connecting…" placeholder shown while reconnecting to saved
hosts. The screen now keeps the welcome copy and add-host actions
visible until the auto-redirect to an online host fires.
2026-05-03 15:05:59 +07:00
Mohamed Boudra
086bc2beee Fix desktop release build args 2026-05-03 15:03:32 +07:00
github-actions[bot]
1eb3b31cfa fix: update lockfile signatures and Nix hash 2026-05-03 07:37:21 +00:00
Mohamed Boudra
831d0bbf6e Hide element picker in production 2026-05-03 14:36:01 +07:00
Mohamed Boudra
d4fa12c3f6 fix(desktop): centralize desktop build script 2026-05-03 14:35:20 +07:00
Mohamed Boudra
f993b1d856 Dismiss keyboard when opening mobile sidebar 2026-05-03 14:17:15 +07:00
github-actions[bot]
f3de568969 fix: update lockfile signatures and Nix hash 2026-05-03 07:00:01 +00:00
Mohamed Boudra
2faacc59f1 chore(release): cut 0.1.65-beta.5 2026-05-03 13:58:46 +07:00
Mohamed Boudra
65cfe4bda4 Reduce agent timeline focus catch-up 2026-05-03 13:57:48 +07:00
Mohamed Boudra
fa20f313d3 Simplify workspace reconnect gating 2026-05-03 13:50:20 +07:00
Mohamed Boudra
b2247450f9 Fix terminal snapshot flushing 2026-05-03 13:25:19 +07:00
Mohamed Boudra
d89ecae615 fix(server): clean up OpenCode process trees
Spawn the shared OpenCode server in its own POSIX process group and terminate provider child process trees with graceful timeout escalation before forcing SIGKILL. Reuse the same helper for Codex app-server cleanup so process-tree behavior is covered consistently across providers.

Add cross-platform unit coverage for macOS/Linux process groups, Windows taskkill cleanup, and timeout escalation. Add the process-tree test to the Windows-critical CI allowlist; Linux full server tests already include it.

Refs #227
2026-05-02 21:44:59 +07:00
github-actions[bot]
24dff12652 fix: update lockfile signatures and Nix hash 2026-05-02 13:42:48 +00:00
Mohamed Boudra
d06e3e9494 chore(release): cut 0.1.65-beta.4 2026-05-02 20:41:37 +07:00
Mohamed Boudra
b7802ae271 chore(release): prep 0.1.65-beta.4 changelog
Update CHANGELOG.md in place per beta policy and add a compat note on the
`sub_agent.actions` field flagging it as drop-on-cleanup once clients
<= 0.1.65-beta.3 are out of the field.
2026-05-02 20:40:41 +07:00
Mohamed Boudra
d53d601043 refactor(server): inline client capability parsing
With capability negotiation in place, the consumer-side tolerance pattern is unnecessary. Drop the dead readDeclaredClientCapabilities helper, inline the parsing in ClientCapabilities.fromHello, delete a tautological assertion in the relay reconnect test, and document the compatibility rules in architecture.md.
2026-05-02 20:30:25 +07:00
Mohamed Boudra
ca318f29cf feat(client): advertise reasoning merge capability 2026-05-02 20:30:25 +07:00
Mohamed Boudra
ee1c7e6956 test(server): pin wire compatibility regressions 2026-05-02 20:30:25 +07:00
Mohamed Boudra
e3e1c08525 fix(server): restore wire compatibility for old clients 2026-05-02 20:30:25 +07:00
Mohamed Boudra
71e83dc42f fix(app): keep live assistant continuation with hydrated tail 2026-05-02 19:57:43 +07:00
Mohamed Boudra
1fcca3b32c fix(app): browser pane keyboard tweaks
- Refresh icon: use single-arrow RotateCw instead of two-arrow RefreshCw
- URL bar selects all text on focus
- Cmd+R now reloads the active browser pane (tracked via IPC) when
  the URL bar has focus, instead of reloading the Electron window
- Forward Paseo shortcut keys (b, e, w, t, k, /, \, comma, digits,
  enter, arrows) from the embedded webview to the main window so
  sidebar/tab/etc. shortcuts still fire when focus is inside the
  loaded page
2026-05-02 19:20:33 +07:00
Mohamed Boudra
e925aa5438 fix(app): keep dictation running when agent tab loses focus
Previously, dictation was cancelled the moment the user switched away
from the active agent tab, losing any in-progress recording. Drop the
auto-stop-on-blur behavior so users can start dictating, switch tabs,
and come back to finalize.
2026-05-02 19:13:31 +07:00
fireblue
654439005a fix(app): paginate canonical timeline catch-up to avoid relay 1009 disconnect loop (#657)
@fireblue diagnosed the production failure mode: long canonical timeline catch-up could exceed the relay WebSocket frame cap, close with 1009, and send clients into a reconnect loop. Their seatbelt fix also removed the `limit: 0` footgun that let app callers request an unbounded frame.

This maintainer pass keeps that diagnosis and redirects the UX around bounded projected pages:

- initial agent load fetches the latest canonical tail
- app resume/reconnect re-fetches the latest tail instead of chaining after-cursor pages
- in-stream gaps still use bounded after-cursor catch-up
- older history loads explicitly when the user scrolls to the oldest edge
- shared page size is TIMELINE_FETCH_PAGE_SIZE = 100 projected timeline items, matching the daemon's canonical projection semantics rather than raw delta chunks

Original diagnosis and fix by @fireblue.

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-02 19:53:50 +08:00
Mohamed Boudra
906205f97a Add browser pane element picker (#670)
* feat: add browser pane element picker

Port the browser pane from PR #198 and route picked elements through workspace attachments so the context follows every agent composer in a workspace.

Co-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>

* docs: document electron platform overlays

* fix: harden browser pane navigation

* fix: polish browser tab interactions

* fix: route browser pane focus shortcuts

---------

Co-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
2026-05-02 19:28:40 +08:00
Mohamed Boudra
34b95e7f01 Improve review comment UI 2026-05-02 18:04:18 +07:00
Mohamed Boudra
49e3011a28 Render sub-agent activity from log 2026-05-02 17:20:22 +07:00
Mohamed Boudra
771f164e26 Stream Codex sub-agents as tool calls (#672) 2026-05-02 17:08:07 +08:00
Mohamed Boudra
ce8dc46054 refactor(server): extract provider turn runner (#668)
Claude, Codex, OpenCode, and ACP each reimplemented the same `run()`
orchestration: pre-start event buffering, turn-id filtering, terminal
event settling, timeline collection, and final-text reduction. Hoist
the loop into `provider-runner.ts` with a small adapter (startTurn,
subscribe, getSessionId) and pluggable final-text reducer.

Net -252 lines across the four providers. Also fixes a latent bug
where a `turn_failed` emitted before startTurn returned was buffered
and silently swallowed instead of rejecting the run.
2026-05-02 15:45:08 +08:00
Mohamed Boudra
b6181c2d31 docs: dark-mode aware star history chart with constrained width 2026-05-02 14:39:33 +07:00
Mohamed Boudra
6225ec3008 refactor(server): extract foreground run state from agent-manager (#666)
Centralize pending-run, waiter, and finalized-turn bookkeeping in a
new ForegroundRunState module so streamAgent, replaceAgentRun,
cancelAgentRun, reload/close, and stream dispatch share one
coordinator instead of hand-rolling the lifecycle in five places.
2026-05-02 15:34:47 +08:00
Mohamed Boudra
26737d3d70 refactor(server): extract WorkspaceDirectory from session.ts (#667)
Move workspace descriptor map building, fetch_workspaces filtering /
sorting / cursor paging, and archiving overlay state out of the Session
god class into a dedicated WorkspaceDirectory module.

Session keeps thin delegation methods so existing tests and callers see
the same surface; cursor errors are still mapped to SessionRequestError
at the session boundary.
2026-05-02 15:34:19 +08:00
Mohamed Boudra
da85814940 fix(server): apply get_agent_activity limit to projected entries
The MCP get_agent_activity tool sliced the raw timeline before passing
to the curator, so `limit: 5` could land mid-message and return a
trailing fragment of streamed assistant text. Now goes through the
existing projection pipeline so the limit counts whole projected
entries — assistant deltas merged, tool calls collapsed by callId,
reasoning chunks merged.

Same bug fixed in the wait_for_agent timeout's "Recent activity"
preview, which used the same raw slice.

Consolidates the merge logic: activity-curator no longer ships its own
collapseTimeline, it delegates to projectTimelineRows. One source of
truth for "what is a complete message".

Drops the meaningless `provider` parameter that was threaded through
the projection module — merge logic never branched on it. The wire
schema keeps `provider` per entry; session stamps it once at the emit
boundary. Adds `reasoning_merge` to the projection kind enum (additive
wire change).
2026-05-02 14:24:45 +07:00
Mohamed Boudra
1b5d082d1a refactor(app): fold timeline sequencing helpers into stream reducer (#664)
Three sibling modules (session-stream-lifecycle, session-timeline-seq-gate,
and the bootstrap-tail policy from session-timeline-bootstrap-policy) only
served the stream reducer. Inline them as private helpers in
session-stream-reducers.ts; keep deriveInitialTimelineRequest exported for
the hook caller. Removes shallow seams without changing behavior.
2026-05-02 15:19:09 +08:00
Mohamed Boudra
03e54a1a21 refactor(server): extract terminal stream router from daemon-client (#665)
Move slot map, listener fanout, binary input encoding, and stream-frame
decoding out of daemon-client.ts into TerminalStreamRouter so the
4.5k-line client no longer owns that bookkeeping. Public DaemonClient
API and on-the-wire behavior unchanged.
2026-05-02 14:56:35 +08:00
Mohamed Boudra
91693aba84 fix(desktop): green up desktop CI on main
Two breakages surfaced when CI started running desktop tests:

- Root vitest.config.ts replaced default exclude with `["**/.claude/**"]`,
  losing `**/node_modules/**`. Desktop has no local vitest config, so
  vitest scanned into `node_modules/zod/src/v4/classic/tests/*.test.ts`
  and failed on missing peers (`@web-std/file`, `recheck`). Spread
  `configDefaults.exclude` so node_modules stays excluded.

- window-manager dark titlebar color changed from #18181c to #181B1A in
  963c7926 (white-flash fix) but the test was not updated. Update the
  expected color to match the new intended behavior.
2026-05-02 13:51:36 +07:00
Mohamed Boudra
f3a4732dbe docs: clarify notifyOnFinish pattern for paseo agents 2026-05-02 13:36:50 +07:00
Mohamed Boudra
90feb8050c feat(desktop): auto-update bundled skills on app launch
Skills installed via Paseo desktop never updated after first run, so
users were stuck on already-fixed skill content. On every launch, if
the paseo base skill is present in `~/.agents/skills/`, sync each
bundled skill (incl. `references/`) into agents, claude (via symlink/
junction), and codex — overwriting only files whose content differs.
Files on disk that are not in the bundle are left alone; deprecation
goes through tombstones (e.g. `paseo-orchestrate` redirects to
`paseo-epic`).

Also refreshes the bundled skill set: drops `paseo-chat`, adds
`paseo-advisor` and `paseo-epic`, and turns `paseo-orchestrate` into
a tombstone redirecting to `paseo-epic`.

Adds a desktop test job to CI on Ubuntu and Windows so the
junction/symlink path is exercised on both platforms.
2026-05-02 13:28:17 +07:00
Mohamed Boudra
d46d92528a fix app stream head replacement during init hydration (#663) 2026-05-02 14:13:39 +08:00
Mohamed Boudra
bb889dae99 feat: stream files as binary frames over WebSocket (#659)
Replace base64-in-JSON file transfers with binary WebSocket frames to
unblock the JS thread when assistant markdown messages contain images.
Receive cost (JSON.parse over multi-MB base64) and persist cost (base64
re-encode to disk) both removed.

- Split daemon-client `exploreFileSystem` into `listDirectory` + `readFile`;
  `readFile` returns `{ bytes, mime, size, modifiedAt }`. Encoding stays
  private to the seam.
- New `packages/server/src/shared/binary-frames/` module hosts terminal
  frames (moved from `terminal-stream-protocol.ts`) and new file-transfer
  opcodes (`FileBegin` / `FileChunk` / `FileEnd`) with request-id correlation.
- Add optional `acceptBinary` to `FileExplorerRequestSchema`. New daemons
  emit binary frames when set; legacy JSON path remains for old clients.
  Compat fallback lives only inside `readFile`.
- New `persistAttachmentFromBytes` writes bytes directly: native via
  `expo-file-system` `File.write(Uint8Array)`, web via Blob -> IndexedDB,
  desktop via new `write_attachment_bytes` IPC. `persistAttachmentFromBase64`
  removed.
2026-05-02 14:02:27 +08:00
Mohamed Boudra
b6c43088fb test(server): align worktree tests with explicit PR context 2026-05-02 12:49:31 +07:00
Mohamed Boudra
fdc510d3a0 feat(opencode): add subagent timeline support (#658)
Map OpenCode `task` tool events into the existing sub_agent timeline shape,
enabling live activity tracking for OpenCode subagent sessions.

- Add `childSessionId` (optional) to sub_agent schema
- Make `actions` optional with default [] for backward compatibility
- Parse task tool input/output into sub_agent detail (tool-call-detail-parser)
- Track child session linkage and buffer/flush child tool parts
- Render sub_agent actions and session IDs in the app component
- Add glob tool detail mapping
- Include backward-compat schema test

Co-authored-by: Ryan Swift <ryan@mlh.io>
2026-05-02 13:19:42 +08:00
Mohamed Boudra
a64be66353 docs(release): credit commit author over PR opener
Squash commits keep the original commit author, but `gh pr view N --json author` returns the PR opener — using that field silently mis-credits work to the maintainer who landed the PR (and the skip-@boudra rule then drops the credit). Document the `--json commits | unique logins` query as the source of truth for attribution.
2026-05-02 12:12:44 +07:00
Mohamed Boudra
2f177dde7a fix(server): rename worktree branches from prompt + attachments
Replace top-level `attachments` and `nameContext` on `create_paseo_worktree_request` with `firstAgentContext: { prompt?, attachments? }`, the single signal driving auto-rename. Drop the `worktreeSlug || …` short-circuits so a random placeholder slug no longer blocks renaming, drop the attachment-as-PR fallback in favor of explicit `githubPrNumber`, and reshape the MCP `create_worktree` tool around a `target` discriminated union (`branch-off` / `checkout-branch` / `checkout-pr`) with concise field descriptions so callers stop misreading `prompt` as a name field.
2026-05-02 12:12:44 +07:00
Mohamed Boudra
8e9134d255 Fix isolated bottom sheet modal lifecycle 2026-05-02 12:12:44 +07:00
Mohamed Boudra
84796bc74d fix(app): restore line numbers in diff gutter and add tap-to-comment
The gutter was showing the message-circle icon on every reviewable line on
native and on compact form factors, displacing the line number whenever
review draft mode was active. Restore the default by computing showAction
purely from interaction state (hover/press), comment presence, and editor
visibility — drop the showPersistentAction flag and the isNative bias.

Tap on the gutter directly opens the inline review editor on every
platform. On native, long-pressing a line of code (350ms) also opens the
editor via a new LongPressableLine wrapper, preserving web text selection.
2026-05-02 12:12:44 +07:00
Ryan Swift
3dae11d292 fix(opencode): include all-mode agents in mode picker (#606) 2026-05-02 12:46:27 +08:00
Mohamed Boudra
4173ac0ca0 refactor(server): extract SortablePager for generic pagination 2026-05-01 23:20:23 +07:00
Mohamed Boudra
9954eefdee refactor(server): extract TerminalSessionController from session.ts
Moves terminal RPC, binary streaming, slot allocation, and exit-subscription
bookkeeping out of the 9.7K-line Session class and into a dedicated controller
with a small facade (dispatch, handleBinaryFrame, killTerminalForClose,
killTerminalsUnderPath, dispose, getMetrics). Session loses 7 fields and 22
methods (-651 LOC) and the cluster's invariants now live next to their state.
2026-05-01 23:05:47 +07:00
Mohamed Boudra
9800203a48 refactor(server): collapse tool-call mapper pass2 zod dance
Replace the duplicated 3-stage Zod pipeline (pass1 -> pass2 envelope
union -> pass2 discriminated union) in each provider mapper with a plain
resolveToolKind(name) function over readonly name sets. Opencode's
toolKind was unused; Claude/Codex used it only as a switch discriminator.
2026-05-01 22:53:14 +07:00
Mohamed Boudra
14d78327c3 refactor: extract tool call status normalization 2026-05-01 22:47:01 +07:00
Mohamed Boudra
6eab2a89e9 refactor(server): extract pagination cursor codec 2026-05-01 22:30:12 +07:00
Mohamed Boudra
c3d2129472 refactor(server): extract AgentActionResponsePayloadSchema
Five set_agent_*_response and update_agent_response payloads were
pixel-identical. Share one named schema so the concept is explicit and
new agent-action responses don't copy-paste the same four fields.
Wire format and inferred types are unchanged.
2026-05-01 21:22:00 +07:00
Mohamed Boudra
829f3f7460 fix(server): put /api/status behind password auth
Only /api/health remains public for liveness probes. /api/status
exposes hostname, version, and server ID which shouldn't be
accessible without authentication.
2026-05-01 21:16:56 +07:00
github-actions[bot]
a4a6713550 fix: update lockfile signatures and Nix hash 2026-05-01 14:12:28 +00:00
Mohamed Boudra
65b814c7dc feat(website): add anchor links to docs headings
Hover a heading to reveal a # link on the left that updates the URL
hash without layout shift, matching GitHub README behavior.
2026-05-01 21:11:14 +07:00
Mohamed Boudra
f2a23039cb docs: document password authentication in public docs 2026-05-01 21:06:29 +07:00
github-actions[bot]
c49743c4a1 fix: update lockfile signatures and Nix hash 2026-05-01 13:19:44 +00:00
Mohamed Boudra
337820a168 chore(release): cut 0.1.65-beta.3 2026-05-01 20:18:40 +07:00
Mohamed Boudra
43d9e2acc8 changelog: update for 0.1.65-beta.3 2026-05-01 20:17:44 +07:00
Mohamed Boudra
3efe6dcff0 fix(server): preserve codex assistant item boundaries 2026-05-01 19:52:19 +07:00
Mohamed Boudra
1fdfefc4de Fix composer workspace attachment update loop 2026-05-01 19:51:58 +07:00
Mohamed Boudra
68d32eb183 test(server): update stale coalescing and worker title tests 2026-05-01 19:18:16 +07:00
Mohamed Boudra
aa547cf66b fix: stabilize streaming footer transition 2026-05-01 19:01:19 +07:00
Mohamed Boudra
a9ea9f3e37 Fix web dropdown menu resizing 2026-05-01 18:32:04 +07:00
Mohamed Boudra
cb319736a2 Fix native-safe connection offer decoding 2026-05-01 18:29:10 +07:00
Mohamed Boudra
51f7ef3171 Fix terminal worker state mirroring 2026-05-01 16:19:25 +07:00
Mohamed Boudra
af38c6e135 fix: restore codex streaming responsiveness 2026-05-01 16:18:46 +07:00
Mohamed Boudra
1c4c22b060 test(server): harden worker terminal cleanup on windows 2026-05-01 14:36:17 +07:00
Mohamed Boudra
e3295290ac test(desktop): smoke packaged terminal cli path 2026-05-01 14:31:22 +07:00
Mohamed Boudra
84f5818e09 test(server): run worker terminal coverage on windows 2026-05-01 14:27:55 +07:00
Mohamed Boudra
67707f2716 test(app): keep script menu e2e focused on launch 2026-05-01 13:25:36 +07:00
Mohamed Boudra
ed70827f39 test(app): stabilize plan approval and script e2e 2026-05-01 13:02:50 +07:00
Mohamed Boudra
111935f4eb ci: run workflows on node 22 2026-05-01 12:32:19 +07:00
Mohamed Boudra
fa0af97aa6 Fix duplicate Codex plan approval panels 2026-05-01 11:42:30 +07:00
Mohamed Boudra
eee4a772d7 Fix imported agent title hydration 2026-04-30 22:42:23 +07:00
Mohamed Boudra
c783b7b093 Fix Codex skill prompt turn startup 2026-04-30 22:36:26 +07:00
Mohamed Boudra
4d840dafd4 Move terminal sessions to worker process 2026-04-30 22:35:22 +07:00
Mohamed Boudra
39f100b4bb Increase archive worktree RPC timeout 2026-04-30 21:41:43 +07:00
Mohamed Boudra
088a6c14c0 feat: direct TCP URI with SSL toggle and optional password auth (#635)
* feat: direct TCP URI with SSL toggle and optional password auth

Replaces the heuristic-driven direct-connection model with a user-controlled
one. The Add Host dialog now exposes structured Host, Port, "Use SSL", and
masked Password fields that compose the canonical
`tcp://host:port?ssl=true&password=xxx` URI used as the storage form.

The daemon gains optional shared-secret auth: `Authorization: Bearer <pw>` on
HTTP and `Sec-WebSocket-Protocol: paseo.bearer.<pw>` on the WS upgrade
(browser WebSocket can't set custom headers). Configured via config.json
`auth.password` or `PASEO_PASSWORD` env. Off by default — old clients keep
working unchanged.

The `port === 443` heuristic for ws/wss is gone; the explicit `useTls` flag
drives scheme selection at every call site.

* fix: stabilize direct tcp auth ci checks

* fix: restore fetch stub in bootstrap smoke test

* fix(app): collapse Advanced section in add-host modal

* feat(server): hash daemon password in config

* fix: update lockfile signatures and Nix hash

* Improve direct TCP auth failures

* Fix workspace cwd updates after rebase

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-30 22:05:49 +08:00
Mohamed Boudra
30f7842e8f feat(cli): connect via relay using pairing offer URL (#639)
Pass `--host https://app.paseo.sh/#offer=...` (or `PASEO_HOST=...`) to
any CLI command and the connection routes through the relay with E2EE,
the same way the mobile and desktop apps connect. Same `paseo daemon
pair` link the app already accepts — no new pairing flow.

Closes #181
2026-04-30 21:37:51 +08:00
Mohamed Boudra
c2ad4a02ef refactor: unify worktree creation workflow (#636)
Route app, MCP, and agent worktree creation through one workflow boundary. Move branch auto-naming into workspace creation and keep agent metadata title-only.
2026-04-30 21:37:17 +08:00
Mohamed Boudra
93c6c3e5f8 fix: keep archived worktrees optimistically hidden (#640) 2026-04-30 21:36:41 +08:00
Mohamed Boudra
92afb826d9 Add inline review comments to git diff pane (#530)
* Add inline git diff review comments

Adds a review draft store keyed per diff target, inline gutter + thread
rendering in the diff pane, and a generated review attachment that flows
through the composer and agent providers.

* Strip generated review attachments before saving to draft store

Drafts persist only UserComposerAttachment (no review kind), so filter the
auto-generated review attachment out when seeding the draft or restoring
after a failed auto-submit.

* Fix inline review typecheck and test harness drift

* Unslop inline review feature

- Fix stale-closure bug in composer queueMessage: clear drafts for the
  actually-queued attachments, not the latest props
- Drop dead useEffect mirroring initialBody in inline review editor
  (parent already remounts via key)
- Restore typed AgentAttachment signature on renderPromptAttachmentAsText;
  delete schema redispatch helper and drop null-checks across 5 providers
- Inline single-use review prompt formatters and replace marker switch
  with const map
- Drop normalizeComment field-copy redundancy after type guard
- Remove __reviewDraftStoreTestUtils export; test migration via
  rehydrate() public path
- Refactor findTarget to return the target line directly (no non-null
  assertion at call site)
- Drop redundant isGeneratedReviewAttachment helper; inline kind check

* Update review pill to withUnistyles after rebase

Main's unistyles refactor stripped useUnistyles in favor of withUnistyles +
StyleSheet. Convert ReviewAttachmentPill to use ThemedCircleDot + ICON_SIZE so
the icon picks up the foreground-muted mapping like the other pills.

* Fix lint and typecheck after rebase onto main

Resolve drift after main's withUnistyles refactor:
- Composer complexity: dedup voice?.isVoiceSwitching to drop to 20
- Move per-comment Pressable handlers in git-diff-inline-review into
  CommentRow subcomponent so callbacks are stable
- Hoist style/onPress factories to module level (iconButton,
  iconButtonDestructive, ghostButton)
- Memoize style arrays in git-diff-pane review wrappers
- Replace inline {} placeholders with useMemo styles
- review-draft-store: extract applyCommentUpdates to avoid map-spread,
  include input.key in useMemo deps
- Test fixtures: hoist EMPTY_COMMENTS, add buildReviewActions helper

* Drop GitDiffPane default param to keep complexity at 20

After the rebase, main's `enabled = true` default param combined with the
inline review additions pushed cyclomatic complexity to 21. Drop the
default and resolve `enabled !== false` at the call site (comparison
doesn't count toward complexity).

* Reshape inline review behind @/review module

Drop the `generated: true` attachment flag and consolidate inline-review
logic behind a deep `@/review` module so composer and git-diff-pane stop
hand-composing review primitives. Workspace-scoped review snapshots are
now derived at submit/queue time rather than persisted in the draft
store. Composer review references drop from 51 to 4; git-diff-pane drops
~108 lines and now consumes a controller hook + slot components.

* Fix optimistic review attachments
2026-04-30 21:35:58 +08:00
Mohamed Boudra
4bcf98a9e3 feat(cli): add paseo import --provider <name> <id> to resume existing sessions (#632)
Surfaces the daemon's existing resume-from-persistence capability through a
new top-level CLI command so users can pull existing Claude Code, Codex, or
OpenCode sessions into Paseo without losing history. Adds a new additive
`import_agent_request` WebSocket message; no existing schemas change.

Refs #611, #237, #492; partially addresses #268.
2026-04-30 21:33:44 +08:00
Mohamed Boudra
2025dee9d0 chore(paseo): point worktree app at worktree daemon port directly
The app service was passing the proxy URL (daemon.<branch>.<slug>.localhost:6767)
which routes through the main daemon. Switch to the worktree daemon's own
ephemeral port so the worktree app talks to the worktree daemon without
a main-daemon hop.

Refs #637.
2026-04-30 18:54:25 +07:00
Mohamed Boudra
5bb177d33b fix(app): split local-daemon bootstrap into default vs configured override
EXPO_PUBLIC_LOCAL_DAEMON is a developer directive that the app must show
the configured host even when disconnected, so the user can see and
diagnose connection failures. The previous bootstrap conflated this with
the default "auto-add localhost:6767 if a daemon answers" behavior, and
the silent inner catch hid every failure mode.

- Split bootstrapLocalhost into bootstrapDefaultLocalhost (probe-then-
  upsert, unchanged) and bootstrapConfiguredOverride (unconditional
  upsert with placeholder serverId local:<endpoint>).
- Reconcile the placeholder to the daemon's real serverId in place when
  the probe receives server-info; rekey controllers and listeners.
- Replace the silent catch with console.warn carrying endpoint and error.
- On every override boot, purge any host owning the override connection
  under a non-matching serverId so a fresh dev daemon (rotated keypair)
  no longer leaves the registry pointing at a defunct host.
- Prune cross-endpoint placeholders when EXPO_PUBLIC_LOCAL_DAEMON changes.

Refs #637.
2026-04-30 18:12:03 +07:00
Mohamed Boudra
4cada20e38 fix(server): make Cursor CLI and other ACP custom providers reliable (#628)
* refactor(server): validate ACP mode/model selection and subscribe to live state

Match Zed's selection validity model: resolve the requested mode/model
against availableModes/configOptions before issuing the ACP RPC, log a
warning instead of throwing on unknown values, and adopt the
currentValue echoed back in setSessionConfigOption responses. Subscribe
to current_mode_update / config_option_update so AgentManager re-emits
state when the agent changes selection unprompted.

Also drops the Copilot Autopilot auto-approve shim to mirror Zed's pure
pass-through requestPermission path.

* refactor(server): emit ACP session config drift via namespaced events

Replace the parallel subscribeToSessionState callback channel with three
payload-bearing variants on the existing AgentStreamEvent union
(mode_changed, model_changed, thinking_option_changed). Manager updates
its record from the event payload and emits state — no callbacks back
into the session. Variants suppressed before websocket dispatch so the
wire schema is unchanged.
2026-04-30 18:43:27 +08:00
Mohamed Boudra
f9e6a1c5ea fix(opencode): surface invalid mode/model errors instead of hanging
The OpenCode adapter sent the prompt before its SSE event subscription
was active. Since /event does not replay, terminal session.error/idle
events for invalid mode or unsupported model could land before the
subscription, leaving the agent stuck in running forever.

Await subscription readiness before promptAsync, and surface synchronous
or rejected prompt errors as turn_failed. Also force-drop sockets in
daemon shutdown so test harnesses don't hang on lingering WS upgrades.

Adds a real-opencode e2e regression test for the invalid-mode case and a
unit test for the synchronous-throw path.
2026-04-30 16:57:00 +07:00
Mohamed Boudra
dec47d72d9 docs: lowercase internal docs + migrate website docs to public-docs/ (#634)
* docs: rename to lowercase + drop leftover plans

Rename docs in docs/ to lowercase kebab-case for consistency and update
all references in CLAUDE.md, CONTRIBUTING.md, CHANGELOG.md,
packages/server/CLAUDE.md, and inter-doc links.

Drop two leftover design plan docs:
- docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md
- docs/plan-approval-normalization.md

* docs: drop stale uppercase entries from case-insensitive rename

* feat(website): power /docs from public-docs/ markdown tree

Move website docs out of TSX route components and into a root-level
public-docs/ directory of plain markdown files with frontmatter
(title, description, nav, order).

- Add packages/website/src/docs.ts loader using import.meta.glob with
  ?raw to compile the markdown into the bundle at build time.
- Replace the 9 hand-written docs/*.tsx routes with a single $.tsx
  catch-all that renders any slug via react-markdown.
- Drive the docs sidebar nav from frontmatter order/nav.
- Auto-discover docs routes in vite.config.ts so the sitemap stays in
  sync without manual edits.

* fix(website): bind dev server to 0.0.0.0 so port collisions trigger fallback

`host: "127.0.0.1"` (or unset) lets macOS coexist with another process
holding an IPv6 dual-stack `*:8082` socket, so Vite never sees
EADDRINUSE and silently binds alongside it. Forcing IPv4 wildcard
makes the conflict real, and Vite's default `strictPort: false`
falls through to the next free port.

* fix(website): restore docs page styling after markdown migration

Add a .docs-prose class that mirrors the styling the original
docs/*.tsx components hand-rolled (h1/h2/h3 sizes, paragraph/list
spacing, link colors, code blocks, callout-style blockquotes).

ReactMarkdown was emitting unstyled HTML because the previous
wrapper class only had inline-code rules — headings and code
blocks fell back to user-agent defaults.
2026-04-30 17:27:15 +08:00
Mohamed Boudra
5d9dc0c4d5 fix(claude): preserve session across query restarts
Fixes the Claude query restart path that could drop conversation context after thinking effort changes or rewind-driven restarts.

Addresses #439.

Related to #156 and #200.
2026-04-30 15:37:31 +07:00
Mohamed Boudra
0b78379b77 fix: percent escaping for git --format on Windows (#629)
* test(server): add real-spawn round-trip for % in argv

RED test: pass --format=%(refname)... through spawnProcess to a child
node script that echoes argv[2]. Asserts the child receives the original
string verbatim. Wired into the Windows CI matrix to expose the
escapeWindowsCmdValue %-doubling bug under cmd.exe.

* test(server): use bare 'node' command so cmd.exe path is exercised

process.execPath has both an extension and a path separator on Windows,
so shouldUseWindowsShell returns false and the broken %-escape pipeline
is never invoked. Use the bare command name 'node' instead.

* fix(server): stop doubling % in cmd.exe arg escaping on Windows

cmd.exe only collapses `%%` → `%` inside batch files; on the command
line / via `cmd /c "..."` `%%` stays literal. Doubling `%` in
escapeWindowsCmdValue meant args like `--format=%(refname)` reached git
as `--format=%%(refname)`, which git interprets as the escape sequence
for a literal `%`, so format atoms appeared verbatim and the branch
picker showed `%(refname)%09%(committerdate:unix)` instead of branch
names.

Update unit tests that pinned the broken doubling behavior.
2026-04-30 14:22:19 +08:00
Mohamed Boudra
6a61066e2c fix(desktop): warn about Rosetta installs on Apple Silicon (#621)
* fix(desktop): warn about Rosetta installs on Apple Silicon

* docs: note workspace build sync before type fixes
2026-04-30 13:13:30 +08:00
Mohamed Boudra
f0a8f207f8 docs: add pull-and-push to 0.1.65-beta.2 changelog 2026-04-30 12:10:52 +07:00
Mohamed Boudra
ae43b6c4cd fix(desktop): skip packaged-app smoke when build arch differs from host
Cross-arch packaging (e.g. arm64 build on an x64 Windows runner)
can't smoke the unpacked binary because the host can't spawn it.
Skip the smoke instead of crashing with spawn UNKNOWN.
2026-04-30 12:09:13 +07:00
Mohamed Boudra
f286a5beea feat(app): add pull-and-push git action (#627)
Combines `git pull` followed by `git push` behind a single action in the
git actions split-button. Push is skipped if pull fails, and errors are
surfaced through the same toast pathway as the existing pull and push
actions.
2026-04-30 12:56:09 +08:00
github-actions[bot]
3f2c33ad46 fix: update lockfile signatures and Nix hash 2026-04-30 04:49:42 +00:00
Mohamed Boudra
461387636a chore(release): cut 0.1.65-beta.2 2026-04-30 11:48:38 +07:00
Mohamed Boudra
0d2a5c04fa docs: 0.1.65-beta.2 changelog 2026-04-30 11:47:20 +07:00
Mohamed Boudra
9c3c0f9140 feat(desktop): build Windows arm64 alongside x64
Multi-arch Windows build in one electron-builder pass, with the website
self-healing across the rename. Asset names go from
`Paseo-Setup-${version}.exe` to `Paseo-Setup-${version}-{x64,arm64}.exe`.

The website now reads the actual installer filename off the latest
GitHub release, so legacy single-arch releases keep rendering a single
"Download" pill while dual-arch releases promote to "Intel / x64" +
"ARM64" automatically. No homepage code change needed when ARM ships.
2026-04-30 11:39:56 +07:00
Mohamed Boudra
a7e1753834 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-30 10:38:26 +07:00
Mohamed Boudra
0aa7c768ba Fix visible changes pane diff subscription 2026-04-30 10:38:07 +07:00
Mohamed Boudra
bdb8f6f535 fix(app): restore tool detail selection affordance 2026-04-30 10:34:44 +07:00
Yurui Zhou
c1cfc7c743 fix(server): preserve fatal log on crash and guard socket.send against close races (#613)
The daemon was dying silently with no log trail. Two compounding causes:

1. uncaughtException/unhandledRejection handlers in index.ts called
   process.exit(1) immediately after logger.fatal, but pino is configured
   with async streams (sync: false console + rotating-file-stream), so the
   fatal entry never flushed. main().catch already worked around this with
   a 200ms setTimeout — extract exitAfterPinoFlush() and apply it to the
   process-level handlers too.

2. socket.send() in the relay transport adapter and ws.send() in
   sendToClient/sendBinaryToClient were unprotected. The ws library throws
   synchronously on send-after-close, and the readyState === 1 check has a
   race window. With an active foreground turn streaming agent_stream
   output, a peer-side disconnect could turn into an uncaughtException —
   which (per #1) then exited without logging.

Wrap the three send sites in try/catch and warn-log on failure; let
onclose/onerror drive cleanup. The readyState fast-path stays as an
optimization.

Refs getpaseo/paseo#612

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:16:48 +08:00
Mohamed Boudra
76c5ec56b5 fix(release): exclude beta versions from mobile release workflow 2026-04-29 21:37:22 +07:00
Mohamed Boudra
a1db839306 fix(release): use bash 3.2-compatible array build for macOS upload
`mapfile` is bash 4+, but macOS GitHub runners ship bash 3.2, so the
upload step exited 127 on both arm64 and x64 jobs. Replaced with a
portable while-read loop.
2026-04-29 20:24:44 +07:00
Mohamed Boudra
3f1c841570 docs: 0.1.65-beta.1 changelog
Add release notes for the 0.1.65-beta.1 beta covering the atomic
manifest fix for the Apple Silicon Rosetta race (#555), the Linux
process-name fix (#602), assistant image loading states, and the
diff gutter alignment fix.
2026-04-29 20:12:51 +07:00
github-actions[bot]
1a0249bf5d fix: update lockfile signatures and Nix hash 2026-04-29 12:56:38 +00:00
Mohamed Boudra
e0cb0302b9 chore(release): cut 0.1.65-beta.1 2026-04-29 19:54:45 +07:00
Mohamed Boudra
4853433ad7 fix(release): publish desktop manifests atomically to close arch race
The macOS publish matrix had each arch run electron-builder with
--publish always, so each runner uploaded its own latest-mac.yml to the
GitHub release the moment it finished. The slower arch clobbered the
faster one, leaving a window (typically 1-3 minutes) where the live
manifest contained only one arch's files[]. Apple Silicon clients
polling during that window could install the x64 build, ending up
under Rosetta.

Also closed the related window where finalize-rollout stamped
releaseDate and rolloutHours after the merged manifest was already
public — auto-updater treats missing rollout fields as "admit", so 100%
of stable users could grab a release before staged rollout kicked in.

Now every build runs --publish never. Each platform job uploads its
non-yml artifacts directly via gh release upload --clobber, and stages
its manifest as an Actions artifact. The renamed finalize-rollout job
downloads all manifest artifacts, merges mac arm64+x64, stamps rollout
metadata, validates, and uploads every manifest in one pass. Public
manifests are never visible in an unmerged or unstamped state.

Refs #555.
2026-04-29 19:47:12 +07:00
Mohamed Boudra
47223b375f feat(app): show loading and error states for assistant images
Render an activity indicator while image dimensions resolve and an
"Image unavailable" fallback if loading fails, replacing the previous
behavior where the surface stayed blank. Reserves a fixed minimum
height in both states so message layout doesn't shift.
2026-04-29 19:47:01 +07:00
Mohamed Boudra
98f2736233 fix(app): keep diff gutter row aligned when line number is blank
The recently added numberOfLines={1} on the gutter <Text> wraps the
content in display:-webkit-box; overflow:hidden on web, where a plain
ASCII space collapses to zero height. That shifted every line number up
by one row, leaving "1" next to the hunk marker. Use a non-breaking
space so the cell keeps its line height.
2026-04-28 21:58:22 +07:00
Mohamed Boudra
f19007cd83 Add diagnostic logging to daemon startup 2026-04-28 21:49:35 +07:00
Mohamed Boudra
2f5f974a0e fix(desktop): match VS Code Linux sandbox handling, scope --no-sandbox to AppImage
The Linux wrapper renamed the executable to Paseo.bin and exec'd it with
--no-sandbox, which surfaced as Paseo.bin in the dock and process list on
Ubuntu. Drop the wrapper and the chrome-sandbox deletion entirely. .deb/.rpm
now ship chrome-sandbox SUID 4755 (electron-builder 26 default postinst,
matches VS Code) and run Paseo directly with the Chromium sandbox on.
AppImage cannot carry SUID, so --no-sandbox is now injected at runtime only
when process.env.APPIMAGE is set.

Fixes #602
Re #381
2026-04-28 21:19:49 +07:00
Mohamed Boudra
88141ab456 fix(server): make /compact slash-command test mock yield session.idle
The empty event-stream mock added in #597 races with the summarize-success
path: consumeEventStream sees the empty stream end immediately and emits a
"stream ended before terminal event" turn_failed which gets buffered before
session.run has assigned its local turnId. The buffered event replays after
startTurn returns, marks settled = true, and rejects completion — but
session.run skips await completion because settled is true, leaving the
rejection unhandled and tripping vitest.

Yield session.idle so the stream resolves the turn the same way real
OpenCode SSE does.
2026-04-28 20:46:40 +07:00
github-actions[bot]
354f64faa8 fix: update lockfile signatures and Nix hash 2026-04-28 13:39:59 +00:00
Mohamed Boudra
17b1d8f732 fix(server): keep setup/teardown/terminal action paths failing loudly on bad paseo.json
Follow-up to the projection fix. The mechanism/policy split landed projection
on a graceful-degrade path, but I also made the setup, teardown, and terminal
wrappers silently absorb parse failures — which broke the existing contract
that `runWorktreeSetupInBackground` surfaces "Failed to parse paseo.json" to
the user when they invoke setup against a workspace with a malformed config.

Restore the throw, but with the path included and the underlying parse error
attached as `cause` (Pino expands it; UI surfaces the path + detail). Extract
`paseoConfigParseError` so spawnWorkspaceScript and the three wrappers throw
the same shape.

Also update opencode-agent.full-access.test.ts mode-order assertion that the
reorder commit forgot to update.
2026-04-28 20:38:11 +07:00
Mohamed Boudra
283390e6d5 chore(release): cut 0.1.64 2026-04-28 20:09:12 +07:00
Mohamed Boudra
dcb04a4802 docs: 0.1.64 changelog and rollout docs
Add 0.1.64 release notes and document the instant-admit release path
(release:patch + immediate desktop-rollout.yml dispatch) so future
fast-rollout releases follow a single canonical flow.
2026-04-28 20:07:25 +07:00
Mohamed Boudra
0dfd909646 fix(opencode): reorder default modes to Build -> Plan -> Full Access 2026-04-28 19:53:27 +07:00
Mohamed Boudra
00219cfd04 fix(server): isolate paseo.json parse failures so they don't block fetch_workspaces
A malformed paseo.json (e.g. git-conflict markers) in any registered
workspace caused readPaseoConfig to throw, which propagated through
getScriptConfigs -> buildWorkspaceScriptPayloads ->
Session.describeWorkspaceRecord and failed the entire
fetch_workspaces_request with code=fetch_workspaces_failed. The client
showed no workspaces at all.

Reshape: separate loading (mechanism) from policy (per-caller).

- readPaseoConfig now returns a discriminated result
  ({ ok: true; config } | { ok: false; configPath; error }). Pure load,
  no logging, no opinions.
- Workspace projection owns its own policy via readPaseoConfigForProjection
  in script-status-projection.ts: warn via pino with configPath +
  workspaceDirectory + err, treat workspace as having no scripts, keep the
  list alive.
- spawnWorkspaceScript throws with cause so the existing
  handleStartWorkspaceScriptRequest catch logs it once with full context.
- The setup/teardown/terminal wrappers retain their pre-existing silent
  degradation on missing/broken config.
- getScriptConfigs takes a parsed PaseoConfig | null instead of repoRoot
  so the projection can pass the result through without double-reading.

Closes https://github.com/getpaseo/paseo/issues/598
2026-04-28 19:48:24 +07:00
Thanh Minh
fbf10ba4c6 fix(server): support OpenCode full-access approvals (#595)
* fix(server): support OpenCode full-access approvals

* fix(opencode): unconditionally auto-approve tool permissions in full-access mode

OpenCode permissions that resolve to allow do not surface prompts in the first place, so checking runtime permission rules before replying made Paseo full-access misleading. Permissions that default to ask, like external_directory and doom_loop, would still surface even in full-access.

Make full-access match OpenCode's own --dangerously-skip-permissions behavior by replying "once" to every tool permission prompt, while still letting reply failures fall back to surfacing the permission to the user.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-28 20:47:12 +08:00
Yurui Zhou
2a4945ad0b fix: keep @ file mention responsive on very large projects (#600)
Typing @ in the chat input to reference a file froze the app on huge
workspaces. Two compounding causes:

1. The autocomplete hook fed fileFilterQuery straight into the React
   Query key, so every keystroke fired a new directory_suggestions
   request and a fresh BFS scan on the daemon. Added a 180 ms debounce
   that mirrors the existing pattern in new-workspace-screen so rapid
   typing coalesces into one request.

2. searchWorkspaceEntries only ignored node_modules, so dist, build,
   target, out, coverage, vendor and __pycache__ each consumed the
   5,000-entry scan budget before any real source file was reached.
   Extended WORKSPACE_IGNORED_DIRECTORY_NAMES with those names and
   added a regression test that proves a deep source file is still
   reachable when those directories are full of bait entries.

Refs #599

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:43:38 +08:00
Thanh Minh
3ba4d09294 fix(opencode): support executable slash commands (#597) 2026-04-28 20:25:04 +08:00
Mohamed Boudra
013e53881b fix(app): prevent infinite loop in @-mention scan when @ is at index 0
`findActiveFileMention` walked back through `@` candidates with
`lastIndexOf("@", atIndex - 1)`, but per spec a negative `position`
clamps to 0 — so once `atIndex` hit 0 with an invalid query (e.g. a
trailing space), the loop stepped from 0 back to 0 forever, freezing
the JS thread on every keystroke and forcing a quit.

Refs #596, #425
2026-04-28 19:16:04 +07:00
Mohamed Boudra
9fb87b9d86 ci(deploy-app): install all workspaces so server/relay sources resolve
Typecheck pulls in @server/* sources via tsconfig path alias, but the
prior `npm install --workspace=@getpaseo/app` skipped server/relay deps,
leaving zod, @anthropic-ai/claude-agent-sdk, and @getpaseo/relay/e2ee
unresolvable on CI.
2026-04-28 18:05:27 +07:00
github-actions[bot]
b557688ab3 fix: update lockfile signatures and Nix hash 2026-04-28 10:45:31 +00:00
Mohamed Boudra
599fcb34c5 chore(release): cut 0.1.63 2026-04-28 17:37:29 +07:00
github-actions[bot]
5a777169fd fix: update lockfile signatures and Nix hash 2026-04-28 10:14:44 +00:00
Mohamed Boudra
470368d910 docs: promote changelog header to 0.1.63 2026-04-28 17:13:21 +07:00
Mohamed Boudra
e41fee0366 fix(desktop): close two pre-ship rollout edge cases
Bucket divisor now produces [0, 1) so the single UUID hashing to
0xFFFFFFFF is no longer permanently excluded by the strict-less-than
admit condition.

Mirrors the post-stamp manifest validation step from desktop-rollout.yml
into desktop-release.yml's finalize-rollout job so a stamp-rollout.mjs
regression can't silently ship a broken manifest.

Adds boundary tests: bucket=0 stays blocked at exact release time,
max-bucket admits at and past rollout end, unparseable releaseDate
admits, bucketFromStagingUserId is strictly < 1 at the upper bound.
2026-04-28 16:58:54 +07:00
Mohamed Boudra
25eb26db0f feat(desktop): add time-based staged rollout for stable updates
Linear ramp from 0% at publish to 100% at releaseDate + rolloutHours
(default 24h, configurable per release). Beta channel and rolloutHours=0
short-circuit to admit everyone. Per-machine bucket derives from a
persistent UUID at <userData>/.updaterId and feeds electron-updater's
isUserWithinRollout hook.

Adds desktop-rollout.yml workflow for in-place rolloutHours edits on
already-published releases (hotfix to 0 or extend the ramp), serialized
against finalize-rollout in desktop-release.yml via a shared concurrency
group keyed on the tag. Replaces the hand-rolled mac manifest merge
with a js-yaml round-trip that preserves unknown fields.
2026-04-28 16:48:18 +07:00
Mohamed Boudra
932a97e447 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-28 14:37:00 +07:00
Mohamed Boudra
3415a77497 docs: update changelog for fixes since v0.1.63-beta.5 2026-04-28 14:32:28 +07:00
Xh74d82hl
814c0660ab fix(loop): pass provider/model to daemon in loop/run (#594)
Fixes #593
2026-04-28 14:54:19 +08:00
Mohamed Boudra
01728e7c22 fix(server): apply provider model overrides to agent default resolution
Built-in provider clients bypassed mergeModels in listModels(), so
configured profileModels/additionalModels were honored by the catalog
but ignored when AgentManager picked a default for new agents.

Wrap the inner client whenever overrides exist (not only when the
provider id differs) and route listModels through mergeModels.

Fixes #579
2026-04-28 13:41:04 +07:00
Edvard Chen
b58e62136e docs: add star history to README (#504) 2026-04-28 14:33:04 +08:00
Mohamed Boudra
7ccf97eb2f fix(server): detect Pi availability via SDK model registry (#586)
The hardcoded OPENAI/ANTHROPIC/OPENROUTER env-var check missed
DEEPSEEK_API_KEY and ~14 other providers the pi-ai SDK supports,
hiding the Pi provider entirely when only those keys were set.
Delegate to ModelRegistry.getAvailable() so every supported provider
is recognized automatically. Diagnostic now lists configured providers
dynamically instead of three hardcoded keys.
2026-04-28 13:31:24 +07:00
Mohamed Boudra
f4fded1472 fix(app): size file preview gutter to fit 3-digit line numbers (#592)
The gutter width formula was calibrated for the diff pane's 12px font,
so 3-digit numbers wrapped to two lines in the file preview's 14px
gutter. Parameterize on font size and add numberOfLines={1} as a guard.
2026-04-28 13:28:02 +07:00
Mohamed Boudra
b23e60a8f4 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-28 13:23:09 +07:00
Mohamed Boudra
ea72c72998 test(server): codify subfolder of archived git repo stays as directory workspace
When a user archives a git repo and later opens a subfolder of it that is
not its own git project, the resulting workspace is a directory — not a
revived git workspace pointing at the archived parent. To get git features
back the user unarchives the parent (S6/S11).
2026-04-28 13:22:33 +07:00
github-actions[bot]
cfe140deb8 fix: update lockfile signatures and Nix hash 2026-04-28 05:39:37 +00:00
Mohamed Boudra
e13634380c chore(release): cut 0.1.63-beta.5 2026-04-28 12:38:33 +07:00
Mohamed Boudra
120c374361 docs: update changelog for 0.1.63-beta.5 2026-04-28 12:36:48 +07:00
Mohamed Boudra
ccf248c332 fix(app): contain triple-click selection to one message bubble
Triple-clicking a message extended the document Range across the
sibling composer and into the textarea, copying both on Cmd+C. Mark
the messages region non-selectable on web and re-enable selection on
each bubble's outer container so triple-click stays scoped.
2026-04-28 12:13:31 +07:00
Mohamed Boudra
05d279cc65 fix(server): surface real errors and child-process output in diagnostics
Provider diagnostics were stringifying Error objects through JSON.stringify,
which yields {} and hides the cause. Spawned children logged stderr to the
daemon log but never attached it to thrown errors, so the diagnostic showed
"Server: Unavailable ({})" with no clue why.

Generalize toDiagnosticErrorMessage to extract message, code, signal, stderr,
stdout, and recursive cause from any Error (including execFile-style errors),
and never return {} or empty. Buffer stderr/stdout in OpenCode startServer
and include them in startup-failure rejections. Stop swallowing errors in
resolveBinaryVersion. Drop the duplicate stringifyUnknownError helpers in
opencode-agent and pi-direct-agent and route every callsite through the
single canonical helper.
2026-04-28 11:58:19 +07:00
Mohamed Boudra
221b76bf52 fix(server): keep archived workspaces out of cwd resolution (#564)
Filter archivedAt records out of the prefix-fallback resolver and stop
canonicalizing into archived parents — opening a child of an archived
git ancestor now creates a fresh workspace at the input path instead
of resurfacing the archived parent.
2026-04-28 11:32:35 +07:00
Mohamed Boudra
b090b4d237 fix(server): align tests with SSH alias resolver and Windows rm flake
- session.test.ts: drop stale 4th workspaceGitService arg from
  createPullRequest assertion (signature changed in ee5a23d9).
- workspace-git-service.test.ts: bump local flushPromises microtask
  count to cover the extra await resolveGitHubRemoteForTarget step
  added by the GitHub remote resolver.
- provider-windows-launch.test.ts: retry rmSync in afterEach to
  ride out Windows file locks from the spawned claude.cmd.
2026-04-27 20:43:01 +07:00
Myriad-Dreamin
c78c30ef54 Support opening workspace branches on GitHub (#583)
* Add GitHub branch open target to workspace button (#96)

* refactor: simplify workspace open targets to flat shape

Collapse the editor/url discriminated union into a single OpenTarget
{ id, label, icon, onOpen } so the button renders targets uniformly
and the mutation is shape-agnostic. Drop the heavily-mocked button
test in favor of the URL builder tests, restore the decodeURIComponent
guard for valid-syntax-but-invalid-UTF-8 pathnames, and note the SSH
host alias limitation.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-27 21:31:11 +08:00
hissin
34626e3b8f fix(server): skip home directory in workspace prefix matching (#563)
Skip the home directory in workspace prefix matching so child paths under the home workspace get their own workspace records.

Closes #562
2026-04-27 20:28:46 +08:00
Jakub Skałecki
ee5a23d962 Support GitHub SSH host aliases for PR actions
Resolves the case where a remote like `git@github-work:owner/repo.git`
points at github.com via `~/.ssh/config` but Paseo treats it as
non-GitHub.

Adds a centralized GitHub remote resolver that parses standard remotes
directly and resolves SSH aliases via `ssh -G` (with hostname
validation to keep the alias out of ssh's option parser). The resolver
is shared by workspace GitHub feature detection, PR status, PR
creation, and workspace git metadata.
2026-04-27 20:28:10 +08:00
github-actions[bot]
ec4f569f6e fix: update lockfile signatures and Nix hash 2026-04-27 10:26:29 +00:00
Mohamed Boudra
304e6d701a chore(release): cut 0.1.63-beta.4 2026-04-27 17:25:25 +07:00
Mohamed Boudra
0245fa59f2 docs: update changelog for 0.1.63-beta.4 2026-04-27 17:24:25 +07:00
Mohamed Boudra
e250024b03 chore: remove scroll jank investigation logging 2026-04-27 17:13:52 +07:00
Mohamed Boudra
40ce317ac2 Reshape desktop bootstrap into independent services
HostRuntime self-boots via boot() and emits state through existing
subscribeAll plumbing. DaemonStartService runs in parallel: on success
it upserts the connection, which HostRuntime's syncHosts already wires
into a controller; on failure it emits an error.

The bootstrap no longer awaits daemon-start before letting saved-host
controllers connect. index.tsx resolves the redirect from
useEarliestOnlineHostServerId + useDaemonStartLastError subscriptions,
not from a procedural promise. The splash error is derived state:
splashError = !anyOnlineHostServerId ? daemonStartError : null. Retry
calls only DaemonStartService.start().

Deletes initializeHostRuntime, setupDesktopManagedConnection,
addConnectionFromListenAndWaitForOnline, bootstrapDesktop on the store,
waitForAnyConnectionOnline (now unused in production), and the
phase enum. Net -623 lines.
2026-04-27 16:36:07 +07:00
Mohamed Boudra
88f382cad3 Fix Cmd+Q on macOS leaving the desktop app alive
The before-quit handler preventDefaulted on every quit and re-fired
app.quit() from .finally. That re-entered Electron through the
window-all-closed gate, which on macOS is a no-op listener that
vetoes the quit — so the window closed but the process stayed up.

Use app.exit(0) to bypass the close → window-all-closed → will-quit
chain entirely. Also drop the daemon-stop NSAlert: with no parent
window it ran app-modal and stalled the child-process pipes for
'paseo daemon stop', so daemon shutdown waited on the user dismissing
the dialog. Replace it with an in-app overlay rendered in the renderer
via a paseo:event:quitting IPC event. Stop opening DevTools on dev boot.
2026-04-27 15:22:16 +07:00
Mohamed Boudra
2ba523ce05 Match shimmer overlay flexShrink to real label row
The shimmerText style forced flexShrink: 1 on the primary label, while
the real label has flexShrink: 0. With long secondary labels, the
shimmer truncated the primary label and shifted the secondary
truncation point, so the shimmer no longer aligned with the actual
text. Drop the override so each shimmer label inherits its base
flexShrink.
2026-04-27 14:40:09 +07:00
Mohamed Boudra
7827adeaf0 Seed paseo.json into new worktrees when missing
Lets first-time users edit project settings via the UI and have those
scripts apply to their first worktree without first having to commit
paseo.json. Committed configs are unaffected — git worktree add already
checks them out, so the seed is a no-op there.
2026-04-27 14:06:23 +07:00
Mohamed Boudra
7212cc2596 Make provider diagnostic modal scrollable on short screens
Drop the flex:1 Models section in favor of a maxHeight, and let the
outer modal scroll so the whole sheet is reachable when the viewport
can't fit it.
2026-04-27 13:51:53 +07:00
Mohamed Boudra
3b66b25f67 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-27 12:49:19 +07:00
Mohamed Boudra
e9ba84ec65 Add open file button to ExpandableBadgeWebShimmerOverlay 2026-04-27 12:45:29 +07:00
Yurui Zhou
df49fbe0ac feat(speech): make OpenAI realtime transcription URL configurable (#570)
Allow `OPENAI_REALTIME_URL` env var to override the hardcoded
`wss://api.openai.com/v1/realtime?intent=transcription` endpoint, with
the current value as default. Pairs with the OpenAI SDK's existing
`OPENAI_BASE_URL` support that already makes HTTP audio endpoints
reroutable. Unblocks pointing the realtime session at any OpenAI
Realtime API protocol-compatible backend (Azure OpenAI, Alibaba
Bailian / DashScope, self-hosted compat layers) without further
changes.

Closes #569

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:26:21 +08:00
Mohamed Boudra
15df680a64 [codex] Centralize subprocess env boundaries (#585)
* Centralize subprocess env boundaries

* Remove env boundary static audit test

* Tighten env boundary process launches

* Fix Windows native process launches

* Inline provider env pass-throughs
2026-04-27 13:24:16 +08:00
Mohamed Boudra
90032570ec Add macOS packaged desktop smoke gate (#578)
* Fix packaged node entrypoint env preservation

* Add packaged desktop smoke gate
2026-04-27 09:08:56 +08:00
Mohamed Boudra
1e5722c996 test(app): align projects/vim e2e specs with current UI
- Navigate Projects via the settings sidebar entry; the top-level
  Projects button never existed and the page lives under
  /settings/projects.
- Match the row's accessibility label (Edit <project>) and the
  textbox label (Worktree setup commands) the screen actually uses.
- Replace the .xterm-rows DOM selector with .xterm-screen for the
  vim layout assertion. With the WebGL renderer, .xterm-rows > div
  is empty, so the test has been red since it was added in 2b372765.
2026-04-26 23:51:02 +07:00
Mohamed Boudra
175685a912 test(app): align startup splash and host-page tests with desktop settings IPC
- Add testID="startup-splash" to the simplified splash screen so e2e helpers
  can detect desktop daemon bootstrap without depending on removed copy.
- Update startup-dsl helper to assert the testID instead of the deprecated
  "Starting local server..." text.
- Replace the obsolete localStorage write for manageBuiltInDaemon in the
  local-daemon sidebar test with a get_desktop_settings IPC mock so the
  desktop bridge surfaces the seeded host without triggering bootstrap.
2026-04-26 23:43:14 +07:00
Mohamed Boudra
29689b15f0 test(app): match inject-mcp-card switch role
Commit 5b47b659 swapped the 'On'/'Off' segmented buttons for a Switch
component (accessibilityRole=switch with label 'Inject Paseo tools').
Update the assertion to match the new role.
2026-04-26 23:32:55 +07:00
Mohamed Boudra
0daf3c209b test(cli): pass --provider explicitly in schedule create test
Commit d3f9655e removed the implicit 'claude' default for --provider on
schedule create. Test 1 still relied on the default, which fails fast
with MISSING_PROVIDER. Pass --provider claude so the assertion path that
expects target=new-agent:claude continues to hold.
2026-04-26 23:07:01 +07:00
Mohamed Boudra
08fbddae4a test(cli): align disabled-provider expectation with daemon message
Daemon emits 'Provider <id> is disabled' for disabled providers (see
session.ts emitProviderDisabledResponse). The test was asserting the
older 'is not available' phrasing, which never matched the actual
output.
2026-04-26 22:46:32 +07:00
Mohamed Boudra
d7659d3f0c test(server): normalize expected cwd in worktree grouping test
classifyDirectoryForProjectMembership normalizes input.cwd via
path.resolve, which on Windows turns "/tmp/repo-feature" into
"D:\\tmp\\repo-feature". Mirror existing pattern (deriveWorkspaceId
tests at lines 91/107) and wrap the expected cwd with
normalizeWorkspaceId so the test passes on both POSIX and Windows.
2026-04-26 22:29:33 +07:00
Mohamed Boudra
e8843a2875 test(app,cli): align mocks and CLI args with intentional API changes
- Add FolderPlus to lucide-react-native mock in left-sidebar.test
  (sidebar started using FolderPlus in 291a1246, mock was stale).
- Pass --provider in cli run daemon-not-running test so it reaches
  the daemon-connect step instead of erroring out at MISSING_PROVIDER
  validation introduced in 64b9b612.
2026-04-26 22:25:14 +07:00
Mohamed Boudra
8a025a6c78 test(server): default mainRepoRoot to null in primitive test helper
Same root cause as 16cf9edf — checkout-git emits mainRepoRoot directly,
so the helper must initialize it to null or downstream isWorktree
derivation flips to true on undefined !== null.
2026-04-26 22:18:32 +07:00
Mohamed Boudra
16cf9edfb1 test(server): default mainRepoRoot to null in createCheckoutStatus helper
de428582 made workspace-git-service propagate checkoutStatus.mainRepoRoot
directly. The test helper never set the field, so the runtime snapshot
emitted undefined where tests expected null.
2026-04-26 22:12:17 +07:00
Mohamed Boudra
eb9492f9b9 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-26 22:04:24 +07:00
Mohamed Boudra
5e7a51364d docs(skill): rewrite paseo skill MCP-first
Replace CLI-only reference with tool-first reference covering worktree,
agent, and schedule tools. Add CLI parity section, desktop CLI fallback
paths, and ops/debugging notes.
2026-04-26 18:38:24 +07:00
Mohamed Boudra
d672bc19de Fix packaged node entrypoint env preservation 2026-04-26 18:34:29 +07:00
github-actions[bot]
c5c03ff956 fix: update lockfile signatures and Nix hash 2026-04-26 10:12:27 +00:00
Mohamed Boudra
f79af3ffbf chore(release): cut 0.1.63-beta.3 2026-04-26 17:11:13 +07:00
Mohamed Boudra
f27598af98 fix(desktop): bundle @getpaseo/cli in packaged app
The CLI module was dropped from desktop deps in ba9ed8b9 (Knip
false-positive — runtime resolves it via createRequire, which static
analysis can't see). electron-builder then shipped 0.1.63-beta.{1,2}
without app.asar/node_modules/@getpaseo/cli, breaking every daemon
status check from the desktop wrapper. Splash startup never saw the
daemon as ready and retries collided with the live pid lock.

Re-declare the dep, and switch private-workspace internal @getpaseo/*
deps to "*" so npm always resolves the workspace sibling and never
falls back to the registry. Publishable workspaces (cli, server) keep
the root-version pin so their npm tarballs reference real ranges.

Update sync-workspace-versions.mjs to preserve that shape on release
bumps, and add a packaging assertion in desktop-packaging.test.ts that
fails fast if a runtime-required workspace dep is removed again.
2026-04-26 17:10:21 +07:00
github-actions[bot]
b7ee28659f fix: update lockfile signatures and Nix hash 2026-04-26 09:18:54 +00:00
Mohamed Boudra
c69b42ac10 chore(release): cut 0.1.63-beta.2 2026-04-26 16:17:44 +07:00
Mohamed Boudra
151a2c513a docs: update 0.1.63-beta.2 changelog entry 2026-04-26 16:16:14 +07:00
Mohamed Boudra
01f89c7c36 refactor: strip useUnistyles in favor of withUnistyles + StyleSheet
Replaces broad useUnistyles() subscriptions in composer, workspace, and
related components with withUnistyles wrappers and StyleSheet.create so
re-renders stay scoped to the tokens actually consumed. Lifts the design
scales (spacing, iconSize, fontSize, etc.) to top-level exports in
theme.ts so component literals share a single source of truth with the
unistyles theme.
2026-04-26 16:12:09 +07:00
Mohamed Boudra
941b4fb760 fix: keep mainRepoRoot optional for backward-compat with old daemons
Newer clients were rejecting checkout_status_response and
project-checkout-lite payloads from older daemons that don't yet send
mainRepoRoot. Make the field optional with a null default so the schema
stays forward-compatible.
2026-04-26 13:44:23 +07:00
Mohamed Boudra
a6b89bfdf7 perf: unblock open_project from gh pr status fetch
Placement only ever needed local git data; fetching GitHub PR status
was incidental coupling through getSnapshot, blocking the response
~5s with no feedback in the desktop app. Split it: getCheckout for
placement (local only), getSnapshot for the watch path. classify is
now a pure function over a checkout. The observer emits the full
gitRuntime/githubRuntime via workspace_update when the background
snapshot lands.
2026-04-26 13:39:08 +07:00
Mohamed Boudra
291a124663 refactor: split sidebar callout from generic Alert primitive
Rename CalloutCard to SidebarCallout (it was always sidebar-specific) and
add a shadcn-style Alert primitive with info/success/warning/error variants
for inline use in screens. Replace the misused SidebarCallout in project
settings with Alert and surface the underlying RPC error message instead
of generic copy. Tune destructive color per theme to a calm tinted red.
2026-04-26 13:16:24 +07:00
Mohamed Boudra
88ea88162c feat: prompt to configure worktree.setup when missing
Replaces the daemon version mismatch sidebar callout with a Set up
worktree scripts callout that watches the active git workspace and
points the user at the project's settings when paseo.json is missing
worktree.setup. Dismissals are sticky per project; read failures are
silent.
2026-04-26 13:06:35 +07:00
Mohamed Boudra
35de4b26e8 chore: gitignore vitest browser failure screenshots 2026-04-26 13:05:10 +07:00
Mohamed Boudra
feeb6c2aa6 fix: answer terminal protocol queries on the daemon, never the browser
Browser xterm.js auto-replies to DA1/DA2/DA3 traveled back over the
websocket and arrived at the PTY after the foreground app had exited,
so the response bytes (\x1b[?62;4;9;22c) landed on the shell prompt as
visible garbage. Move all replies to the daemon's headless xterm where
they reach the PTY synchronously, and suppress every query class on
the browser side. The image addon also registers a {final:"c"} handler
inside the post-mount raf, so re-register suppression after addons
load to stay last in xterm's LIFO dispatch.

Replaces the previous printf-injection test with a real foreground
helper that queries DA1 and asserts the reply lands on stdin, plus a
browser table covering DA1/DA2/DA3 + DSR/CPR/DECRQM.
2026-04-26 12:47:50 +07:00
Mohamed Boudra
7f2592041b Merge branch 'research/paseo-json-rpc' 2026-04-26 11:56:13 +07:00
Mohamed Boudra
712cb8fc86 fix: surface Claude Grep results in search detail body
The Claude SDK delivers Grep tool_result content as a plain string, which
claude-agent.ts wraps as { output: <string> } for unrecognized tools. The
canonical ToolGrepOutputSchema requires structured fields, so parsing fell
through to an unknown detail and the UI rendered only the redundant query.
2026-04-26 11:56:01 +07:00
Mohamed Boudra
219050a7ab feat: project settings page with paseo.json editor
Adds a Projects settings flow for inspecting and editing per-project
paseo.json (worktree setup/teardown, scripts, terminals) directly from the
app. Reads/writes go through new daemon RPCs with revision-based optimistic
concurrency, and the screen always keeps its header and host picker visible
so a hung host doesn't trap the user.
2026-04-26 11:37:47 +07:00
Mohamed Boudra
18903bcecb refactor: simplify startup splash screen to logo with shimmer
Replace text-heavy splash ("Welcome to Paseo", "Starting local
server...", "Connecting...") with just the logo centered with a
shimmer sweep effect. Error state preserved as-is.
2026-04-26 11:30:07 +07:00
Mohamed Boudra
a2c68cc689 feat: restore last workspace on cold start
Persist the user's last active workspace and navigate directly to it
on cold start instead of always landing on the open-project screen.

- Unified bootstrap into one cross-platform wait step after
  platform-specific connection setup
- Store-level wait with workspace preference: preferred host online
  resolves immediately, grace period if a different host connects
  first, bounded 5s timeout for unreachable hosts
- index.tsx is fully declarative — reads resolved target from context,
  no useEffect/promises/refs/timers
2026-04-26 11:28:46 +07:00
Mohamed Boudra
843d9234bd refactor: tighten tool call badge hover affordances
Move the open-file button inline after the summary text so the click
target sits next to what it acts on, and replace the tool icon with the
chevron on hover instead of showing a separate chevron at the row's end.
Hovering the icon slot keeps the tool icon visible for predictability.
2026-04-26 11:11:42 +07:00
Mohamed Boudra
5b47b6594f refactor: replace settings buttons with switches and simplify provider sheet
Swap daemon management and keep-running-after-quit buttons for Switch
toggles. Remove the restart/start daemon control. Simplify the provider
diagnostic sheet to reuse shared settings styles and SettingsSection.
Drop the unused "sm" size variant from the Switch component. Remove
trailing periods from hint text for consistency.
2026-04-26 08:15:28 +07:00
Mohamed Boudra
521944f19b feat: add open-file button to tool call badges
Show a file-symlink icon on hover that opens the file referenced by read,
edit, write, and simple shell commands (cat, head, tail, etc). Extracts
the file path from tool call details via a new utility with tests.
2026-04-26 08:15:16 +07:00
Mohamed Boudra
1ec448d2b9 fix: prevent bootstrap phase regression when an existing host comes online first
When a saved host reconnects before bootstrapDesktop finishes, the phase
was regressed from "online" back to "connecting". Track the race settlement
and skip the setPhase("connecting") call if anyOnline already won. Also
extract getEarliestOnlineHostServerId into HostRuntimeStore and use it to
initialize the bootstrap phase, avoiding a flash of the starting screen.
2026-04-26 08:15:06 +07:00
Mohamed Boudra
b7310b9e90 feat(desktop): simplify quit lifecycle with sync PID check and shutdown dialog
Replace the async daemon status resolution during quit with a synchronous
PID file check, avoiding races when the event loop is shutting down. Show
a blocking dialog so users know the daemon is stopping. Add --kill-timeout
to the CLI stop command for the desktop app to control SIGKILL wait time.
2026-04-26 08:14:55 +07:00
Mohamed Boudra
f9fcd6e032 Merge branch 'feature/desktop-daemon-settings' 2026-04-25 14:08:46 +07:00
Mohamed Boudra
cfc27f2576 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-25 14:07:13 +07:00
Mohamed Boudra
4dd3b0006b Accept string form for worktree setup/teardown
Lifecycle config now takes either a multiline shell script string or an
array of commands, making it friendlier to edit in a textarea. Schema
parsing moved to Zod so coercion stays in one place.
2026-04-25 14:06:56 +07:00
github-actions[bot]
a8ffbc8003 fix: update lockfile signatures and Nix hash 2026-04-25 07:04:36 +00:00
Mohamed Boudra
4b3603604a feat: enable/disable providers from settings
Adds a per-provider toggle in Settings → Providers, persisted via daemon
config, so users can turn off providers they don't have installed (or
don't want to use) without removing them from the registry.

Disabled providers stay visible everywhere — settings, `paseo provider
ls`, MCP `list_providers`, model/mode lists — marked as disabled rather
than silently filtered. Availability probes are skipped for them, and
attempts to launch an agent or fetch models/modes from a disabled
provider return a clear "Provider '<id>' is disabled" error.

Schema additions (`enabled` on `ProviderSnapshotEntry`, MCP provider
summary) are backward-compatible: optional with a default of true so
older clients continue to parse new daemon messages.

CLI: `paseo provider ls` gains an ENABLED column; JSON output includes
the new field.
2026-04-25 14:00:38 +07:00
Mohamed Boudra
ae3e88e7e1 feat(desktop): split daemon and release settings to main process
Move daemon management and release channel settings out of the renderer's
AsyncStorage and into a desktop-owned settings store persisted by the main
process. Adds a "Keep daemon running after quit" option, a daemon lifecycle
section in settings, and a one-time migration of legacy renderer-owned
desktop settings.
2026-04-25 13:59:07 +07:00
Mohamed Boudra
de42858213 fix: reopen worktrees under the right project 2026-04-24 21:43:23 +07:00
Mohamed Boudra
db8934c53e fix: keep unavailable provider agents in history 2026-04-24 21:43:09 +07:00
Mohamed Boudra
4b7199f53b feat: improve provider settings and model selection 2026-04-24 21:42:47 +07:00
Mohamed Boudra
d3f9655e33 fix: require providers for new CLI agents 2026-04-24 21:40:59 +07:00
Mohamed Boudra
2b37276577 Fix initial terminal resize for nvim 2026-04-24 15:57:13 +07:00
Mohamed Boudra
83689ef0c5 fix(app): restore assistant link color 2026-04-24 14:03:49 +07:00
Mohamed Boudra
9a4528129f fix: suppress terminal query response leaks 2026-04-24 13:58:53 +07:00
Mohamed Boudra
231ebdcfb1 fix: preserve zsh prompt in packaged desktop (#544) 2026-04-24 13:54:20 +07:00
Mohamed Boudra
ef418d5292 Fix git diff header truncation 2026-04-24 13:27:11 +07:00
github-actions[bot]
d0f7d93fc4 fix: update lockfile signatures and Nix hash 2026-04-24 06:13:24 +00:00
Mohamed Boudra
32d9147499 chore(release): cut 0.1.63-beta.1 2026-04-24 13:12:14 +07:00
Mohamed Boudra
ad0e754cc1 docs: add 0.1.63-beta.1 changelog entry 2026-04-24 13:11:20 +07:00
Mohamed Boudra
d152b4f055 Improve terminal renderer recovery 2026-04-24 13:05:58 +07:00
Mohamed Boudra
c92e4e46d2 Stabilize git diff list heights 2026-04-24 12:26:36 +07:00
Mohamed Boudra
691e2ee7e6 Preserve live stream head during timeline catch-up 2026-04-24 12:01:33 +07:00
Mohamed Boudra
6b84c8963d Scope pre-commit lint and format to staged files 2026-04-24 12:00:26 +07:00
Mohamed Boudra
0c3d15ffb9 Stabilize web timeline height estimates 2026-04-24 11:55:43 +07:00
Mohamed Boudra
480b18c500 Document targeted npm lint and format scripts 2026-04-24 11:52:42 +07:00
Mohamed Boudra
f9e18424e9 Require lint after changes 2026-04-24 11:49:16 +07:00
Mohamed Boudra
52eb307e8c Stop logging ACP non-JSON stdout previews 2026-04-24 11:42:59 +07:00
Mohamed Boudra
50a12e2140 Reduce assistant file link parser complexity 2026-04-24 11:34:37 +07:00
Mohamed Boudra
d118e2349e Fix assistant file links with line suffixes 2026-04-24 11:32:16 +07:00
Mohamed Boudra
446dd9cfe6 chore: drive linter to zero, promote warn -> error in CI (#545)
* chore(lint): use Set for LOG_FORMATS membership check

* chore(lint): convert type aliases to interfaces (autofix)

- Remove no-use-before-define rule (conflicts with unistyles ordering)
- Add typescript/consistent-type-definitions: interface
- Run oxlint --fix: 606 type->interface conversions across 268 files

Typecheck green. Warnings: 5432 -> 3787 (-1645).

* chore(lint): clean up relay package warnings

* chore(lint): clean up cli package warnings

* chore(lint): flatten nested ternaries in server (no-nested-ternary)

* chore(lint): escape entities and hoist inline objects in website

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* fix(types): restore Record assignability after type->interface autofix

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* chore(lint): clean up desktop and highlight packages

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* chore(lint): format keyboard-shortcuts-section

* chore(lint): rename shadowed bindings in server (no-shadow)

Rename inner bindings that shadow outer imports or function params:
- Promise executor `resolve` -> `resolvePromise` (shadowed path `resolve`)
- Method params `options` -> `input`/`target`/`update`/`runOptions`/`opts`/`killOptions`
- Misc loop/destructure renames for `workspaceId`, `scriptNames`, `path`, `query`, `taskNotificationItem`

Mechanical change only; no behavior change.

* fix(types): add index signatures for Record assignability

* chore(lint): extract nested callbacks in server (max-nested-callbacks)

* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)

Work in progress: 30 of 369 warnings fixed across 23 files.

* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)

* chore(lint): reduce cyclomatic complexity in server

* chore(lint): hoist inline callbacks in app components (jsx-no-new-function-as-prop)

* chore(lint): parallelize safe await-in-loop in server

* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)

Finish eliminating jsx-no-new-function-as-prop warnings in
agent-status-bar.tsx and complete refactor of git-diff-pane.tsx
by extracting per-item components and using stable useCallback
handlers.

* chore(lint): parallelize more safe await-in-loop in server

* fix(server): restore microtask ordering in message dispatch and stream event handler

The complexity refactor added async dispatcher chains that inserted extra
microtasks before message handlers ran, and wrapped the stream event switch
in an await that fired between emitState and dispatchStream. Two tests
regressed on both counts. Route to the matching dispatcher synchronously
and skip the await when the stream handler has no async work.

* chore(lint): hoist/memoize inline arrays and objects in app JSX

* chore(lint): parallelize safe awaits in server (no-await-in-loop)

Converts three sequential `for ... await` loops to `Promise.all`:
- OpenCode: configure MCP servers in parallel
- Sherpa: download model files and ensure multiple models concurrently
- Speech runtime: check required model files across models in parallel

* chore(lint): clear website warnings to zero

Resolves all 61 oxlint warnings in packages/website/ by hoisting inline
callbacks/JSX, extracting sub-components to satisfy jsx-max-depth, using
stable data-derived keys, adding explicit button types, flattening nested
ternaries, and removing unused code.

* chore(lint): parallelize more safe awaits in server (no-await-in-loop)

- Workspace reconciliation: archive missing workspaces and orphaned
  projects concurrently instead of sequentially.
- Directory suggestions: resolve child directory candidates in parallel
  before filtering.

* chore(lint): allow css imports in import/no-unassigned-import, hoist host page styles

* chore(lint): hoist dictation-controls inline arrays

* fix(server): restore microtask ordering in session message dispatch

Converts per-group dispatchers to Promise<void> | undefined. The complexity
refactor broke two tests by inserting extra microtasks between each
dispatcher's await. The new pattern routes synchronously via a nullish-
coalescing chain and awaits only the matching dispatcher's promise.

* chore(lint): flatten nested ternaries in button/volume-meter/autocomplete

* chore(lint): narrow explicit any in misc server files

* chore(lint): flatten nested ternaries in host-runtime/host-page/providers

* chore(lint): clear desktop warnings to zero

Convert polling loops to recursive helpers to avoid no-await-in-loop,
and switch the ws import to the named export so the WebSocket type is
referenced directly.

* chore(lint): remove useless constructors in server tests

* chore(lint): hoist pair-scan inline arrays and objects

Extracts corner style arrays and barcode scanner settings to module
scope, memoizes insets-dependent body/helper text styles.

* chore(lint): hoist inline style arrays in question-form-card

* chore(lint): hoist/memoize inline styles in plan-card

Extracts markdown rule style arrays into dedicated MarkdownInlineText,
MarkdownListItemContent, and MarkdownParagraph subcomponents that
memoize their own style arrays. Memoizes PlanCard container/title/
description styles.

* chore(lint): narrow explicit any in server relay/loader/logger-likes

* chore(lint): hoist SheetBackground combined style

* chore(lint): hoist inline styles in screen-header

* chore(lint): hoist inline styles/objects in menu-header

* chore(lint): hoist top-level inline arrays in diff-viewer

* chore(lint): narrow explicit any in codex-app-server-agent thread items

* chore(lint): reduce max-depth in claude-sdk-behavior test

* chore(lint): memoize inline styles in command-center

* chore(lint): extract helper to reduce max-depth in agent-response-loop

* chore(lint): flatten nested conditions in pi-direct-agent history

* chore(lint): reduce max-depth in claude-agent query pump

* chore(lint): clear cli warnings to zero

Refactor polling loops into recursive helpers to satisfy
no-await-in-loop, fix WebSocket named import, and correct
an absolute-path import in tests/tmp.

* chore(lint): memoize inline styles in context-menu

* chore(lint): narrow explicit any in claude-agent/openai stt

* chore(lint): reduce max-depth in opencode-agent foreground loop

* chore(lint): memoize inline styles in explorer-sidebar

* chore(lint): memoize mobile sidebar styles in explorer-sidebar

* chore(lint): memoize inline styles in agent-list

* chore(lint): reduce relay test no-await-in-loop warnings

Refactor while-loops and for-retry loops in e2e.test.ts and
live-relay.e2e.test.ts into recursive poll/attempt helpers.
Remaining 8 warnings in encrypted-channel.ts concern the custom
Transport interface (on-handler slots, not DOM EventTarget) and a
sequential send loop; both reflect deliberate runtime contracts.

* chore(lint): fix typecheck errors from hoisting refactors

- pair-scan: type BARCODE_SCANNER_SETTINGS as BarcodeSettings
- explorer-sidebar: add missing desktopSidebarStyle useMemo
- sidebar-workspace-list: coerce null dotColor to transparent
- menu-header, e2e.test: formatting

* chore(lint): memoize inline styles in workspace-screen

* chore(lint): memoize inline styles in workspace-desktop-tabs-row

* chore(lint): hoist constant style arrays in desktop-updates-section

* chore(lint): memoize inline styles in combobox

* chore(lint): extract SplitGroupChild to memoize inline styles

* chore(lint): memoize inline styles in dropdown-menu

* chore(lint): memoize inline styles in workspace-hover-card

* chore(lint): memoize inline styles in autocomplete, message-input, composer

* chore(lint): remove explicit any in server (129 warnings)

- session.ts: catch(error: any) -> catch(error) with Error coercion at use
- daemon-client transport: any -> unknown in listener types
- sherpa/onnx loaders: introduce structural native types
- pocket-tts-onnx: typed ONNX session inputs/outputs/tensors
- tests: any -> unknown + named stub types

* chore(lint): hoist inline arrays and objects in app (145 warnings)

- Hoist static style arrays/objects to module-level consts
- Memoize dynamic ones with useMemo and correct deps
- jsx-no-new-array-as-prop: 145 -> 43
- jsx-no-new-object-as-prop: 64 -> 21

* chore(lint): memoize inline styles in _layout

Fixes 3 react-perf/jsx-no-new-object-as-prop warnings in root layout by
memoizing the stack screen options, the agent screen override, and the
GestureHandlerRootView root style.

* chore(lint): memoize inline styles and objects in stream/status panes

Clears 10 react-perf/jsx-no-new warnings across welcome-screen,
file-explorer-pane, terminal-pane, agent-stream-view, and
agent-status-bar by extracting per-item subcomponents, hoisting constant
style tuples, and memoizing derived arrays/objects.

* chore(lint): memoize inline style objects in list/pane components

Clears 6 react-perf/jsx-no-new-object-as-prop warnings across
synced-loader, stream-strategy-web, sortable-inline-list, file-pane, and
both draggable-list platform variants by memoizing or hoisting their
inline style objects.

* chore(lint): hoist test fixture arrays/objects

Clears 14 react-perf/jsx-no-new-* warnings across app test files by
hoisting constant fixtures to module scope or wrapping them in helper
factories so they no longer appear as inline JSX prop values.

* chore(lint): memoize inline styles in message components

Clears 5 react-perf/jsx-no-new-* warnings in message.tsx by memoizing
image source objects, extracting an AssistantMessageBlockContainer for
per-block spacing, and moving todo list item rendering into a
TodoListItemRow subcomponent so each row memoizes its own style arrays.
The remaining 8 warnings in react-native-markdown-display render rules
are inherent to that library's rule API.

* chore(lint): remove unused vitest imports in server tests

* chore(lint): remove unused helpers and vars in server tests

* chore(lint): add explicit returns in then() callbacks in server

* chore(lint): fix no-shadow and no-nested-ternary in server

* chore(lint): flatten nested blocks to satisfy max-depth in server

* chore(lint): extract helpers to satisfy max-depth in server

* chore(lint): parallelize Claude persisted agents lookup

Replace sequential parseClaudeSessionDescriptor loop with Promise.all
to fix no-await-in-loop warning.

* chore(lint): parallelize Linux watch directory traversal

Switch BFS to level-by-level Promise.all over readdir calls, replacing
the sequential pop/await loop that triggered no-await-in-loop.

* chore(lint): parallelize workspace registry bootstrap upserts

Collect per-workspace upsert inputs in a sync pass, then run registry
writes via Promise.all to eliminate no-await-in-loop warnings.

* chore(lint): parallelize test daemon cleanup rm calls

Run the paseoHomeRoot/staticDir removals concurrently in close() and
the startup catch block to fix no-await-in-loop.

* chore(lint): mechanical cleanup in app (unused, shadow, nested-ternary)

Recovers in-flight edits from the app mechanical agent that couldn't be
pushed due to concurrent tree contention. Removes unused helpers and
locals, flattens nested ternaries, narrows a few props where unused.

* chore(lint): parallelize pending permission approvals

approvePendingPermissions now filters and records handled IDs in a
sync pass, then awaits all allowPermission calls concurrently instead
of looping awaits.

* chore(lint): parallelize rebase head-name lookup

Try both rebase backends (rebase-merge, rebase-apply) concurrently
via Promise.all and return the first non-null result.

* chore(lint): no-unused-vars in app

* chore(lint): parallelize worktree terminal bootstrap

Start each bootstrap terminal concurrently via Promise.all. Since every
spec creates an independent terminal and returns a standalone result,
parallelization preserves order while removing two no-await-in-loop
warnings.

* chore(lint): parallelize worktree bootstrap test cleanup and script spawn

Run terminal cleanup across managers and spawnWorkspaceScript for api
and web concurrently to eliminate no-await-in-loop warnings while
preserving semantics.

* chore(lint): prefer-add-event-listener in app

* chore(lint): parallelize script-health-monitor afterEach and spawns

Close all stub TCP servers concurrently and spawn typecheck/api
scripts via Promise.all to drop two no-await-in-loop warnings.

* chore(lint): jsx-no-useless-fragment in app

* chore(lint): parallelize executable PATH probing

* chore(lint): parallelize client and relay transport test cleanup

* chore(lint): parallelize agent storage record file probing

* chore(lint): no-array-index-key in app

* chore(lint): parallelize bootstrap provider availability cleanup

* chore(lint): parallelize Codex rollout file search

* chore(lint): parallelize dictation wav fixture search

* chore(lint): parallelize Claude session id fixture test

* chore(lint): parallelize script health monitor server cleanup

* chore(lint): parallelize Codex skills directory scan

* chore(lint): promise/always-return in app

* chore(lint): no-shadow in use-dictation, hoist icons in splash screen

* chore(lint): no-shadow in composer callbacks

* chore(format): apply oxfmt to 3 server files

* chore(lint): no-explicit-any in stt-manager/chat-mentions tests

* chore(lint): no-shadow in small app files

Rename inner shadowing identifiers in:
- add-host-modal, agent-status-bar, sidebar-workspace-list, terminal-pane
- workspace-setup-dialog, session-context, use-is-local-daemon
- setup-panel, new-workspace-screen, workspace-desktop-tabs-row

* chore(lint): no-explicit-any in claude-agent.test

* chore(lint): no-shadow in small app test/e2e files

* chore(lint): no-explicit-any in workspace-git-service.test

* chore(lint): no-shadow in app test hoisted blocks

* chore(lint): no-explicit-any in websocket-server.runtime-metrics.test

* chore(lint): no-explicit-any in acp-agent.test

* chore(lint): no-shadow in app composer/message-input/e2e helpers

* chore(lint): no-explicit-any in session.workspace-git-watch.test

* chore(lint): no-explicit-any in websocket-server.notifications.test

* chore(lint): no-shadow in sidebar-workspace-list.test, use-pr-pane-data.test

* chore(lint): no-explicit-any in websocket-server.relay-reconnect.test

* chore(lint): no-explicit-any in codex-app-server-agent.test

* chore(lint): no-shadow in composer.test, host-runtime.test, new-workspace-screen.test

* chore(lint): no-explicit-any in codex-app-server-agent.features.test

* chore(lint): hoist jsx-as-prop in app components batch 1

Memoize inline JSX icons passed as props across add-host-modal,
branch-switcher, composer, pair-link-modal, pr-pane, and
sidebar-workspace-list to satisfy react-perf/jsx-no-jsx-as-prop.

* chore(lint): no-explicit-any in session.test

* chore(lint): hoist jsx-as-prop in app components batch 2

Memoize inline JSX passed as props across agent-list, agent-status-bar,
file-explorer-pane, git-actions-split-button, and message to satisfy
react-perf/jsx-no-jsx-as-prop.

* chore(lint): hoist jsx-as-prop in app components batch 3

Memoize inline JSX passed as props across combined-model-selector,
draggable-list.native, provider-diagnostic-sheet, and
workspace-setup-dialog to satisfy react-perf/jsx-no-jsx-as-prop.

* chore(lint): hoist jsx-as-prop in desktop components

Memoize inline JSX passed as props across desktop-permissions-section,
desktop-updates-section, integrations-section, and pair-device-section
to satisfy react-perf/jsx-no-jsx-as-prop.

* chore(lint): no-explicit-any in mcp-server.test

* chore(lint): hoist jsx-as-prop in workspace/new-workspace screens

* chore(lint): hoist jsx-as-prop in remaining screens

* chore(lint): no-explicit-any in session.workspaces.test

* chore(lint): no-explicit-any in snapshot-mutation-ownership.test

* chore(lint): no-explicit-any in dictation-stream-manager.test

* chore(lint): no-explicit-any in relay-transport.e2e.test

* chore(lint): no-explicit-any in worktree-session.test

* chore(lint): no-explicit-any in model-resolver.test

* chore(lint): no-explicit-any in sherpa-parakeet-stt.test

* chore(lint): no-explicit-any in speech-download.e2e.test

* chore(lint): no-explicit-any in script-health-monitor.test

* chore(lint): no-explicit-any in schedule/service.test

* chore(lint): no-explicit-any in persistence-hooks.test

* chore(lint): no-explicit-any in editor-targets.test

* chore(lint): no-explicit-any in send-while-running-stuck-test-utils.test

* chore(lint): no-explicit-any in agent-storage.test

* chore(lint): no-nested-ternary in app batch 1 (18 files)

* chore(lint): no-explicit-any in generate-sherpa-tts-matrix

* chore(lint): no-explicit-any in claude-agent

* chore(lint): no-explicit-any in websocket-server

* chore(lint): no-explicit-any in process-conversation

* chore(lint): no-explicit-any in daemon-client

* chore(lint): no-explicit-any in codex-app-server-agent

* chore(lint): no-nested-ternary in app batch 2 (26 files)

* chore(format): apply oxfmt to server test files

* chore(lint): parallelize agent archiving in test-mcp-inject

* chore(lint): no-explicit-any in small app files (batch 1)

* chore(lint): no-explicit-any in small app files (batch 2)

* chore(lint): no-explicit-any in app (batch 3)

Covers use-web-scrollbar, stream-strategy, dictation-stream-sender test,
terminal-perf helper, checkout-git-actions-store test, and
web-desktop-scrollbar.

* chore(lint): no-explicit-any in app (batch 4)

Covers composer, message-input, tooltip, workspace-desktop-tabs-row,
and the e2e helpers (app, node-ws-factory, terminal-probes).

* chore(lint): no-explicit-any in polyfills/crypto.ts

* chore(lint): no-explicit-any in components/sidebar-workspace-list.tsx

* chore(lint): prefer-array-find over filter().at/pop

Replace filter(pred).at(-1)/pop() patterns with findLast(pred) and
filter(pred)[0] with find(pred) across server and app.

* chore(lint): no-explicit-any in components/message.tsx

* chore(lint): no-explicit-any in components/plan-card.tsx

* chore(lint): no-map-spread replace with Object.assign

Replace { ...obj, override } inside map callbacks with Object.assign({}, obj, { override })
to satisfy oxc/no-map-spread. Preserves copy-on-write semantics.

* chore(lint): no-extraneous-class in test mocks

Add dispose() stubs to xterm addon mocks to satisfy typescript-eslint/no-extraneous-class, and convert static-only Notification mocks to plain objects.

* chore(lint): no-named-as-default use named imports for ws and openai

* chore(lint): jsx-no-new-array-as-prop hoist composed style in volume-display

Memoize the style array so it is not created inline on every render.

* chore(lint): prefer-add-event-listener use Object.assign for non-DOM handlers

These transports (Transport interface, StreamableHTTPServerTransport) use
plain on<event> properties rather than EventTarget, so addEventListener is
not an option. Using Object.assign to set the handlers avoids the lint
false-positive without changing behavior.

* chore(lint): no-multiple-resolved null pendingResolve after settling

Replace boolean-settled guards with a nullable captured resolve/reject.
Each promise callback captures the resolver, nulls it on first use, and
calls it. This preserves the existing single-resolution semantics while
making the no-multiple-resolved rule happy.

* chore(lint): reduce structural complexity in server

Extract nested-callback cleanup helpers in worktree-bootstrap.test. Extract Codex model definition builder to drop list-models complexity below 20.

* chore(lint): extract helpers in agent-activity and session-store-hooks.test

Split tool-call update handling in groupActivities into helpers to clear max-depth. Hoist useWorkspaceFields selector in the hooks test to drop callback nesting.

* chore(lint): rules-of-hooks and exhaustive-deps in agent-stream-view

Move useMemo calls for permission card styles above the early return
so hooks run unconditionally. Add missing dependencies to the inline
path press handler and the stream render callback.

* chore(lint): rules-of-hooks in diff-scroll via optional context hook

Replace try/catch around useExplorerSidebarAnimation with a new
useExplorerSidebarAnimationOptional hook that returns null when the
provider is absent.

* chore(lint): rules-of-hooks and exhaustive-deps in sidebar-workspace-list

Replace conditional useMemo calls with plain inline arrays, capture
creatingWorkspaceTimeoutsRef.current in the effect body before cleanup,
and depend on the full input object in armTimers.

* chore(lint): rules-of-hooks in split-container

Hoist useWorkspaceLayoutStore and useMemo calls above the pane-kind
early return so hooks always run in the same order.

* chore(lint): rules-of-hooks in context-menu and dropdown-menu

Inline the resolvedWidthStyle object inside the content useMemo and move
the early return below the hook so it is always invoked in the same order.

* chore(lint): rules-of-hooks in web-desktop-scrollbar

Move thumbRegionStyle and handleStyle useMemo calls above the visibility
early return.

* chore(lint): reduce complexity in types/stream

Split reduceStreamUpdate timeline branch into per-case helpers (tool call and compaction) to clear complexity and max-depth warnings.

* chore(lint): reduce complexity in diff-highlighter

Extract metadata detection, path extraction, hunk parsing, and content-line push into helpers to drop parseDiff complexity below 20.

* chore(lint): exhaustive-deps in composer and use-attachment-preview-url

Memoize githubSearchItems so the picker callbacks don't see a new array
every render, hoist realtimeVoiceButtonStyle above rightContent useMemo
that depends on it, and add missing style dependencies. Tighten
use-attachment-preview-url to read the attachment via a ref while keying
off stable field primitives.

* chore(lint): reduce complexity in tool-call-detail-state

Extract per-detail helpers for hasMeaningfulToolCallDetail to clear complexity warning.

* chore(lint): reduce complexity in agent-grouping

Split groupAgents into partition, project-activity map, active-project, and inactive-date helpers.

* chore(lint): reduce max-depth in voice-runtime

Extract retireFinishedGroup helper to flatten processPlaybackQueue nesting.

* chore(lint): reduce max-depth in use-branch-switcher

Extract maybeRestoreStashForBranch callback to flatten handleBranchSelect nesting.

* chore(lint): exhaustive-deps in components batch

Address exhaustive-deps warnings across file-explorer-pane, file-pane,
git-diff-pane, message, provider-diagnostic-sheet, stream-strategy-web,
terminal-emulator, terminal-pane, and volume-meter. Memoize values that
otherwise changed every render, read non-reactive values through refs
when the effect intentionally tracks a different key, and add missing
dependencies where they were genuinely absent.

* chore(lint): reduce complexity/max-depth in draft-store

Extract collectQueuedMessageAttachmentIds and collectStreamUserImageIds helpers from runAttachmentGc. Extract buildMigratedDraftRecord helper for migratePersistedState.

* chore(lint): reduce complexity in tooltip and workspace-scripts-button

Extract resolveActualSide and resolveAlignedCoordinate helpers in tooltip. Extract resolveScriptIconColor in workspace-scripts-button.

* chore(lint): exhaustive-deps in contexts and hooks batch

Fixes exhaustive-deps warnings across app contexts and hooks by adding
missing deps, stabilizing derived arrays with useMemo, capturing ref values
at effect entry, and using ref pattern where deps were intentionally omitted.

* chore(lint): final hook-rules in workspace-screen, editor-button, stores, e2e

Memoizes derived arrays (availableEditors, terminals) to stabilize
useMemo deps, adds missing deps (normalizedServerId, normalizedWorkspaceId,
explorerToggleStyle, workspaceIdsKey, eventName), and renames the Playwright
fixture callback parameter from `use` to `provide` so eslint-plugin-react-hooks
doesn't conflate it with React's use() hook.

* chore(lint): reduce complexity/max-nested-callbacks in workspace-tabs-store

Extract migrate function body into migrateWorkspaceTabsState top-level
helper with per-loop sub-helpers (migrateUiTabsForKey, mergeExplicitTabOrder,
mergeLegacyTabOrder, migrateFocusedTabIds) and replace the inline IIFE in
ensureTab with buildNextTabsForEnsure helper.

* chore(lint): reduce complexity in session-store and panel-store

Extract isSessionServerInfoUnchanged helper from updateSessionServerInfo
in session-store. In panel-store split migrate body into per-version
migration helpers (migratePanelV2Explorer, migratePanelV3Explorer,
migratePanelExplorerTabByCheckout, migratePanelDesktopFocusMode) and
a top-level migratePanelState function.

* chore(lint): reduce complexity in keyboard-shortcuts and use-keyboard-shortcuts

Split resolveKeyboardShortcut into resolveInitialChordStep and
resolveAdvancingChordStep helpers with a shared buildMatchFromBinding
factory. Split handleAction's large switch into handleDispatchOnlyAction,
handlePayloadAction, handleSettingsToggle, and handleCommandCenterToggle
helpers, with hasPayloadKey type guard replacing inline payload checks.

* chore(lint): reduce complexity in audio recorder web hooks

Extract assertMicrophoneEnvironment helper from useAudioRecorder start
callback. Split useDictationAudioSource stop callback with
disconnectDictationAudioGraph, stopMediaRecorderIfActive,
finalizeRecorderStoppedPromise, and safeDisconnectNode helpers.

* chore(lint): reduce complexity in use-git-actions

* chore(lint): reduce complexity in use-pr-pane-data

* chore(lint): reduce complexity in use-agent-autocomplete

* chore(lint): reduce complexity in use-agent-screen-state-machine

* chore(lint): reduce complexity in session-stream-reducers

* chore(lint): reduce complexity in desktop-updates-section

* chore(lint): reduce complexity in pair-device-section

* chore(lint): reduce complexity in update-callout-source

* chore(lint): reduce complexity in provider-diagnostic-sheet

* chore(lint): disable no-await-in-loop (sequential-by-necessity cases)

Most remaining no-await-in-loop warnings were in legitimate sequential
patterns: polling loops with sleep, streaming/pagination cursors, shared
mutable state, ordered side effects (audio playback, port allocation).
Parallelizing these would change observable behavior. Rather than force
restructures that add complexity without benefit, disable the rule.

* chore(lint): reduce complexity in setup-panel

Extract helpers (resolveAutoExpandIndex, resolveSetupStatusLabel,
resolveCommandLog, buildCommandRowState) and lift the standalone log
view and top-level error banner into dedicated sub-components to drop
SetupPanel below the complexity limit.

* chore(lint): reduce complexity in AssistantMarkdownImage

Extract error-text resolution into a helper so the image component
drops below the cyclomatic complexity threshold.

* chore(lint): reduce complexity in ProjectLeadingVisual

Extract the status-variant dispatch into a ProjectLeadingVisualStatus
sub-component so the outer function stays below the cyclomatic
complexity limit.

* chore(lint): extract ChatAgentContent agent-building helper

* chore(lint): reduce complexity in ChatAgentContent

Extract the chat-agent selector (selectChatAgentState +
resolveChatAgentFromSession), the agent-shape constructor
(buildChatAgentFromState), and the not-found/error/boot view
dispatch (renderChatAgentNonReadyView) into helpers so both the
selector callback and the component function drop below the
cyclomatic complexity limit.

* chore(lint): extract subcomponents to reduce JSX nesting depth

* chore(lint): reduce complexity in app components (status-bar, explorer, setup-dialog, tool-call-details)

* chore(lint): reduce complexity in app hooks/runtime/stores/misc components

* chore(lint): long-tail cleanup (no-async-endpoint-handlers, unicorn rules, unescaped-entities, unassigned-import allowlist)

* chore(lint): simplify agent useMemo deps to match helper signature

* chore(lint): rename ChatService.postMessage to dispatchMessage

* chore(lint): server cleanup (no-shadow, no-useless-*, no-unmodified-loop, control-regex, require-yield)

* chore(lint): app cleanup (jsx-max-depth, jsx-no-new-*-as-prop, complexity, misleading-regex, no-useless-*)

* chore(lint): e2e cleanup (no-unmodified-loop-condition, max-nested-callbacks)

* chore(lint): workspace-screen complexity + no-empty-pattern cleanup

* chore(lint): use Array.from for defensive snapshot iteration

* chore(lint): final mechanical tail (control-regex, max-depth, useless-expressions, examples cleanup)

* chore(lint): reduce agent-stream-view complexity

* chore(lint): reduce resolveAgentModelSelection complexity

* chore(lint): reduce globalSetup complexity

* chore(format): oxfmt draft-store single-line signature

* chore(lint): reduce GitDiffPane complexity

* chore(lint): reduce MessageInput complexity

* chore(lint): reduce Combobox complexity

* fix(server): bound listLinuxWatchDirectories readdir concurrency

Wraps the per-level readdir traversal in listLinuxWatchDirectories with a
module-level p-limit (default 16, tunable via PASEO_LINUX_WATCH_READDIR_CONCURRENCY).
On broad repos, the previous Promise.all over an entire BFS level could issue
an unbounded number of concurrent readdir calls. Behavior is otherwise
identical: still traverses all non-.git directories and swallows per-directory
readdir failures.

* chore(lint): promote oxlint warnings to errors

- package.json: lint/lint:fix now pass --deny-warnings so any warning fails
- .github/workflows/ci.yml: add lint job running npm run lint
- .oxlintrc.json: disable no-empty-pattern for e2e/fixtures.ts
  (Playwright requires the empty object destructure as the first arg)
- packages/app/e2e/fixtures.ts: restore async ({}, provide) signature
- packages/app/e2e/global-setup.ts: oxfmt single-line signature

* fix(test): relay-transport ws mock exports WebSocket named binding

Commit 38efad7d switched relay-transport.ts from default to named import
of ws.WebSocket, but the test mock still only exported default. Updated
vi.mock("ws") to return both default and named WebSocket bindings so the
mocked module matches the new import shape. Classification: test drifted
with production code; test now exports what the production import needs.

* chore(lint): make oxlint error at config level; add Lefthook pre-commit

Replaces --deny-warnings workaround with semantic severity at the config
level, and adds whole-repo pre-commit gates via Lefthook.

- .oxlintrc.json: all enabled categories (correctness, suspicious, perf)
  and intentional rule overrides now use "error" instead of "warn".
  Removed duplicate "require-await" key (last-wins "off" preserved).
- package.json: lint scripts are plain oxlint / oxlint --fix. Adds
  lefthook devDep and "prepare": "lefthook install --force" so hooks
  install on npm install even where core.hooksPath is set globally.
- lefthook.yml: pre-commit runs format:check, lint, typecheck in
  parallel. Whole-repo gates (no glob filters). Block-only, no
  auto-formatting.

* fix: update lockfile signatures and Nix hash

* chore(lint): pre-commit runs formatter in write mode, then lint+typecheck

Switches the pre-commit hook from format:check to format (write) with
stage_fixed: true, so the formatter rewrites files and re-stages them
into the commit. Lint and typecheck then run in parallel against the
formatted tree.

Sequencing uses Lefthook 2.x jobs with a nested group: top-level jobs
run sequentially, so the format job completes before the checks group
starts; within checks, lint and typecheck run in parallel.

CI continues to use format:check, so unformatted code pushed with
--no-verify still fails on the server.

* chore(lint): revert pre-commit to format:check (no stage_fixed)

stage_fixed: true with a whole-repo formatter (oxfmt .) can silently
re-stage the full reformatted file when a developer used git add -p
to stage only a hunk, losing their hunk-level intent. Safer to just
block the commit on unformatted code and let the dev run
`npm run format` themselves.

Reverts to the simple parallel check form: all three commands run
concurrently and are read-only.

* chore(ts7): tsgo compatibility across the monorepo

Preserves tsc 5.9.3 behavior while making TypeScript 7 / tsgo
(@typescript/native-preview 7.0.0-dev.20260423.1) green
package-by-package.

tsconfig:
- server/tsconfig.server.json: drop removed `baseUrl`; rewrite
  `@server/*` paths entry to be relative to the tsconfig.
- website/tsconfig.json: drop removed `baseUrl`; rewrite
  `~/*` paths entry to be relative.
- expo-two-way-audio/tsconfig.json: replace inherited
  moduleResolution=node10 with `bundler`, set `module: esnext`
  (required by `bundler`). Matches Metro/Expo runtime behavior
  and avoids forcing `.js` extensions in source.

source:
- app/e2e/helpers/terminal-probes.ts: WebSocket subclass `send`
  now types its argument as `Parameters<WebSocket["send"]>[0]`
  so the subclass signature tracks the platform type across
  tsc 5.9 and TS 7 libdom.
- relay/src/crypto.ts: tweetnacl setPRNG callback allocates a
  fresh Uint8Array for getRandomValues (owning ArrayBuffer) and
  copies into the caller-provided view. TS 7 libdom requires
  ArrayBufferView<ArrayBuffer> (not ArrayBufferLike).

All 8 packages green under both tsc --noEmit and tsgo --noEmit.

* fix(app): update os-notifications test mocks for addEventListener

Commit 46731a51 switched attachWebClickHandler from notification.onclick
to notification.addEventListener("click", ...) to satisfy the
prefer-add-event-listener lint rule. The test mocks still asserted
the old onclick property, so two specs failed on CI with
"notification.addEventListener is not a function". Update the three
MockNotification classes to record click listeners through
addEventListener and drive them via clickListeners[0] in the
assertions.

* chore: switch typecheck from tsc to tsgo (@typescript/native-preview)

Installs @typescript/native-preview 7.0.0-dev.20260423.1 as a root
devDependency and swaps every per-workspace `typecheck` script from
`tsc --noEmit` to `tsgo --noEmit`. Build scripts still use `tsc`, so
emit behavior is unchanged. Monorepo typecheck drops from ~28s to
~4.7s wall clock (~6x faster).

expo-two-way-audio previously ran `tsc` without --noEmit for its
typecheck step. tsgo is stricter about rootDir on emit, so the
typecheck script explicitly passes --noEmit here too.

* fix: update lockfile signatures and Nix hash

* fix(app): commit *.css module declaration for CI typecheck

expo generates expo-env.d.ts (which references expo/types where
*.css is declared) locally, but the file is gitignored. CI does a
fresh checkout + install and doesn't run expo, so the declaration
is missing and tsgo rejects the side-effect import
'@xterm/xterm/css/xterm.css' with TS2882. Add a committed
packages/app/global.d.ts with 'declare module "*.css"' so typecheck
is self-contained.

tsc tolerated the missing declaration more loosely; tsgo is stricter.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-24 12:26:10 +08:00
Mohamed Boudra
5b2f3fd830 chore: fix unresolved imports and tighten knip cross-workspace entries
Delete orphaned voice-mcp-agent-smoke.ts (voice-mcp-bridge was removed
in cff41d78). Fix model-catalog.e2e.test.ts import path. Extend knip
server entries to cover shared/client cross-workspace imports.
2026-04-23 22:41:27 +07:00
Mohamed Boudra
2dd8c27573 chore: delete unused files flagged by knip
38 files removed across app and server (verified unreferenced via
grep + typecheck). Includes dead components, hooks, stores, utils,
POC scripts, an empty orchestrator stub, stale playwright configs,
and a duplicate workspace-registry test-helpers module.

knip config tightened: added babel.config.js, native platform
extensions, and test-stubs/ to app entry patterns.
2026-04-23 22:29:49 +07:00
github-actions[bot]
dc92863c35 fix: update lockfile signatures and Nix hash 2026-04-23 15:26:07 +00:00
Mohamed Boudra
ba9ed8b995 chore: remove unused deps and declare missing ones
Knip-driven cleanup across all workspaces:

Removed (verified zero imports):
- root: @anthropic-ai/claude-agent-sdk, @modelcontextprotocol/sdk,
  expo, react, react-native, react-dom (duplicates of workspace deps)
- server: @ai-sdk/openai, @deepgram/sdk, express-basic-auth,
  fast-uri, mnemonic-id, tiny-invariant, playwright
- app: @expo/vector-icons, @getpaseo/server,
  @react-navigation/bottom-tabs, @react-navigation/elements,
  base64-js, react-native-css, expo-av, expo-font, expo-symbols,
  expo-web-browser, react-native-permissions,
  react-native-popover-view, react-native-securerandom
- cli: @getpaseo/relay
- desktop: @getpaseo/cli
- relay: @cloudflare/workers-types
- website: tailwindcss (provided by @tailwindcss/vite)

Declared (previously hoisted, now explicit):
- root: @types/ws, playwright, ws (used by scripts/)
- server: @mariozechner/pi-ai, @mariozechner/pi-agent-core
- app: expo-asset, qrcode, @types/qrcode, tiny-invariant,
  @xterm/headless, dotenv, jsdom
- cli: vitest
- desktop: vitest

Also removes the stray `/app.json` (obsolete, overridden by
packages/app/app.config.js).
2026-04-23 22:20:33 +07:00
Mohamed Boudra
2f381a76ed chore: add knip config for unused-code detection
Workspace-aware entry points plus ignore lists for script-only tools
and tools knip's binary resolution misses. Adds `npm run knip`.
2026-04-23 22:13:12 +07:00
Mohamed Boudra
cda3d1d46c chore: apply oxlint fix-suggestions (unused imports + useless fragments)
Scoped to only `no-unused-vars` and `react/jsx-no-useless-fragment` —
the safe subset of --fix-suggestions. Primary changes:

- Remove unused named imports from statements
- Remove unused default/type imports entirely
- Collapse `<><Single/></>` fragments to `<Single/>`

Verified clean: typecheck + format + lint all green.
2026-04-23 21:55:25 +07:00
Mohamed Boudra
731c570b25 fix(types): repair typecheck after oxlint safe autofix
The safe --fix introduced three type issues:

1. unicorn/prefer-set-has rewrote array literals to `new Set(...)`
   without updating the `T[]` type annotation — broken assignment.
   Fix type to `Set<T>` in logger.ts and agent-manager.ts.

2. unicorn/no-array-reverse changed `.reverse()` → `.toReversed()`
   (ES2023). Bump `lib` to ES2023 in tsconfig.base.json and
   tsconfig.server.json. Target stays at ES2020 — Node 20 supports
   the methods natively, no downlevel needed.

3. preserve-caught-error added `new Error(msg, { cause })` (ES2022),
   also covered by the lib bump.
2026-04-23 21:55:19 +07:00
Mohamed Boudra
177153e5e8 chore: apply oxlint autofix (safe level)
Auto-fixed 102 warnings (6040 → 5938) across 57 files. Main categories:
- preserve-caught-error: add { cause: error } to re-thrown errors
- unicorn/no-array-reverse: .reverse() → .toReversed()
- unicorn/no-useless-fallback-in-spread: drop unnecessary ?? {} in spreads
- oxc/no-map-spread: rewrite object spread in .map() to Object.assign
- react-hooks/exhaustive-deps: trim module-level constants from dep arrays

Ran npm run format after to clean up whitespace from lint mutations.
2026-04-23 21:47:36 +07:00
Mohamed Boudra
9c88850de0 chore: replace biome with oxc (oxfmt + oxlint)
- Remove @biomejs/biome from root and packages/expo-two-way-audio
- Delete biome.json
- Add .oxfmtrc.json (migrated from biome via oxfmt --migrate=biome)
- Add .oxlintrc.json with curated rule set: react, react-perf,
  react-hooks, typescript, unicorn, oxc, import, promise
  - Rules target functional gotchas, untyped code, perf issues,
    deep JSX, nested ternaries, complexity, unused code
  - Noisy stylistic rules explicitly disabled
- Add npm scripts: lint, lint:fix (oxfmt replaces format/format:check)
- Update CI to run oxfmt --check

Lint is wired up but not enforced — all rules at warn. Baseline:
6040 warnings, 0 errors.
2026-04-23 21:44:56 +07:00
Mohamed Boudra
ce4b0d54c7 chore: reformat codebase with oxfmt
Apply prettier-compatible formatting across the repo to match the
incoming oxfmt configuration. Mechanical reformat only — no logic
changes. Covers YAML quote normalization, package.json key sorting,
Markdown/TOML formatting, and minor TS whitespace tweaks.
2026-04-23 21:44:48 +07:00
Mohamed Boudra
9b25f8fc36 Auto-submit iOS builds for App Store review via fastlane
Adds a fastlane lane and an EAS Workflow custom job that runs after
submit_ios. Picks up the latest TestFlight build, waits for Apple
processing, and submits for review with auto-release on approval.

Uses ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 secrets from the EAS
production environment.
2026-04-23 21:36:35 +07:00
Mohamed Boudra
26e6d97e81 Auto-submit Android to production track
Was uploading to internal track as draft, requiring manual release in
Play Console. Now rolls out to 100% on production after Google review.
2026-04-23 21:21:58 +07:00
Mohamed Boudra
80102753e1 Make worktree archive optimistic 2026-04-23 21:16:23 +07:00
github-actions[bot]
75b8ae640c fix: update lockfile signatures and Nix hash 2026-04-23 13:30:33 +00:00
Mohamed Boudra
e521e1f79d chore(release): cut 0.1.62 2026-04-23 20:28:53 +07:00
Mohamed Boudra
a62c7471d2 docs: add 0.1.62 changelog entry 2026-04-23 20:27:40 +07:00
Mohamed Boudra
3edf812578 Fix PR worktree status lookup 2026-04-23 20:10:48 +07:00
Mohamed Boudra
dfc8223bcc Make workspace git registration nonblocking (#542) 2026-04-23 18:21:59 +08:00
Mohamed Boudra
083cd34db4 Merge branch 'scan-cleanup-opportunities' 2026-04-23 15:28:34 +07:00
Mohamed Boudra
9abc0217e9 refactor: extract types and simplify activity grouping 2026-04-23 15:28:25 +07:00
Mohamed Boudra
576cc0b668 Add sidebar callout surface 2026-04-23 15:19:40 +07:00
Mohamed Boudra
e21ef2fc9b fix: prevent old release website redeploys 2026-04-23 10:17:37 +07:00
Mohamed Boudra
c8b61f7ddc Show release contributors from synced notes 2026-04-23 09:17:45 +07:00
Mohamed Boudra
9c5974735e Fix desktop CLI environment boundary 2026-04-23 07:08:24 +07:00
Mohamed Boudra
f65f82bac9 fix(release): keep changelog notes authoritative 2026-04-23 06:43:18 +07:00
github-actions[bot]
9a35f35bab fix: update lockfile signatures and Nix hash 2026-04-22 23:34:38 +00:00
Mohamed Boudra
8cc7e8951c chore(release): cut 0.1.61 2026-04-23 06:33:09 +07:00
Mohamed Boudra
38a41a71ff docs: promote 0.1.61 changelog 2026-04-23 06:32:11 +07:00
Mohamed Boudra
ce6393a933 fix(app): avoid hashing image preview payloads 2026-04-22 23:15:28 +07:00
github-actions[bot]
339a0b6b43 fix: update lockfile signatures and Nix hash 2026-04-22 15:39:01 +00:00
Mohamed Boudra
1f7b4eb10f chore(release): cut 0.1.61-beta.1 2026-04-22 22:37:42 +07:00
Mohamed Boudra
3560fb58bb docs: add 0.1.61-beta.1 changelog entry 2026-04-22 22:36:27 +07:00
Mohamed Boudra
4140e640fe feat(app): store assistant image previews as assets 2026-04-22 22:28:56 +07:00
Mohamed Boudra
434b28fc8e Add Providers docs page
Splits first-class and custom provider docs out of the Configuration
page onto /docs/providers. Introduces the first-class providers
(claude, codex, opencode, copilot, pi) and covers extending a provider,
Z.AI and Qwen plans, multiple profiles, custom binaries, ACP agents,
additionalModels, and disabling. Points to docs/CUSTOM-PROVIDERS.md on
GitHub as the source of truth.
2026-04-22 22:24:12 +07:00
Mohamed Boudra
1f8f8e4036 Reduce workspace rerenders during explorer resize 2026-04-22 22:18:11 +07:00
Mohamed Boudra
b133285f6d Add additive custom provider models 2026-04-22 22:16:54 +07:00
Mohamed Boudra
4d5889ff38 Load Pi extensions for model discovery 2026-04-22 22:12:49 +07:00
github-actions[bot]
de57f301f9 fix: update lockfile signatures and Nix hash 2026-04-22 13:40:36 +00:00
Mohamed Boudra
95c773ce6d chore(release): cut 0.1.60 2026-04-22 20:38:54 +07:00
Mohamed Boudra
c6680b3001 fix(release): preserve 0.1.60 schema compatibility 2026-04-22 20:37:19 +07:00
Mohamed Boudra
484e453401 Fix timeline catch-up for projected overlap 2026-04-22 20:21:26 +07:00
Mohamed Boudra
4b867c2f41 Fix mobile tool call expansion while streaming 2026-04-22 20:05:41 +07:00
Mohamed Boudra
368ce0339a Lazy-load GitHub picker queries 2026-04-22 20:00:43 +07:00
Mohamed Boudra
8e63274ce6 fix(server): unify worktree archive paths 2026-04-22 19:59:00 +07:00
Mohamed Boudra
d80c524e0e fix(app): restore archive and composer CI checks 2026-04-22 19:16:28 +07:00
Mohamed Boudra
89efb007f9 Fix Codex replacement turn idle flash 2026-04-22 18:54:20 +07:00
Mohamed Boudra
407b4b68b0 Recover branch during rebase 2026-04-22 18:35:01 +07:00
Mohamed Boudra
637109eab0 Fix workspace hover card safe zone 2026-04-22 18:22:20 +07:00
github-actions[bot]
3a0161d051 fix: update lockfile signatures and Nix hash 2026-04-22 11:08:00 +00:00
Mohamed Boudra
027b3839fc chore(release): cut 0.1.60-beta.2 2026-04-22 18:06:55 +07:00
Mohamed Boudra
c3589bdc84 chore: refresh lockfile metadata 2026-04-22 18:05:56 +07:00
Mohamed Boudra
a5e6cc2d2b docs: note beta sanity fixes 2026-04-22 18:04:32 +07:00
Mohamed Boudra
713c90ea42 fix(server): restore release-blocking lifecycle checks 2026-04-22 18:03:37 +07:00
Mohamed Boudra
e51fdada51 docs: update changelog for 0.1.60-beta.2 2026-04-22 17:58:50 +07:00
Mohamed Boudra
dd98ae9359 fix(server): checkout fork PRs into owner branches 2026-04-22 17:53:53 +07:00
Mohamed Boudra
274a7fd217 fix(server): preserve dev runner signal handling 2026-04-22 17:53:53 +07:00
Mohamed Boudra
a582fccef0 fix(app): clear new workspace drafts after submit 2026-04-22 17:53:53 +07:00
Mohamed Boudra
019bf65790 Fix running-agent replacement lifecycle 2026-04-22 17:53:53 +07:00
Mohamed Boudra
9218f70df3 Remove leftover app debug logging 2026-04-22 17:53:53 +07:00
therainisme
fd05bf8bff fix(server): preserve Codex fast mode after plan approval
Codex was clearing fast mode after approving a plan.

This keeps fast mode and plan mode independent. Approving a plan still exits plan collaboration mode, and the follow-up implementation turn now keeps serviceTier=fast when fast mode was already enabled.

Verified locally:
- node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/providers/codex-app-server-agent.test.ts --bail=1
- node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/agent-manager.test.ts --bail=1
- npm run typecheck
2026-04-22 18:51:57 +08:00
Mohamed Boudra
9e3d8f0ec5 fix(cli): send version during daemon handshake 2026-04-22 16:19:51 +07:00
Mohamed Boudra
43b9123c5c fix(app): restore workspace tab focus on refresh 2026-04-22 16:19:43 +07:00
Mohamed Boudra
b6afc66131 fix(app): avoid settings header offset 2026-04-22 16:19:31 +07:00
Mohamed Boudra
7993dc02dc Add working indicator top spacing 2026-04-22 16:06:34 +07:00
Mohamed Boudra
6601db2673 Fix stream action spacing branches 2026-04-22 15:49:00 +07:00
Mohamed Boudra
e4badd95f4 Restore stream auxiliary spacing 2026-04-22 15:44:29 +07:00
Mohamed Boudra
02fc354686 Gate desktop daemon dev providers in production 2026-04-22 15:42:36 +07:00
Mohamed Boudra
aef54d5180 Optimize agent stream rendering 2026-04-22 15:38:53 +07:00
Mohamed Boudra
3c5bfd6e3d Stabilize sidebar workspace press handler 2026-04-22 14:14:34 +07:00
Mohamed Boudra
f422dabf1e Reduce redundant runtime updates 2026-04-22 14:03:59 +07:00
Mohamed Boudra
2706d5e934 fix(app): avoid duplicate bootstrap connection client 2026-04-22 13:26:45 +07:00
Mohamed Boudra
6b210df471 fix(app): stabilize startup connection routing 2026-04-22 13:11:41 +07:00
Mohamed Boudra
9a39ba4be0 Default agent ls to active scope 2026-04-22 12:52:52 +07:00
Mohamed Boudra
fc86ce2a5f Tighten Escape agent interrupt shortcut 2026-04-22 12:52:47 +07:00
Mohamed Boudra
6edf19c7c6 fix(app): streamline startup loading states 2026-04-22 12:13:23 +07:00
Mohamed Boudra
80c319dc49 Avoid git refresh for agent status updates 2026-04-22 12:07:57 +07:00
Mohamed Boudra
77c8c91ab5 Fix dev server Ctrl-C shutdown wait 2026-04-22 12:03:46 +07:00
Mohamed Boudra
6503245493 Avoid GitHub self-heal polling without remote 2026-04-22 11:21:37 +07:00
Mohamed Boudra
184902dd0d Merge branch 'perf/startup-rpc-metrics' 2026-04-22 11:03:41 +07:00
Mohamed Boudra
42cdabdf57 Fix optimistic workspace creation on Android 2026-04-22 11:03:35 +07:00
Mohamed Boudra
247983ee87 Improve startup provider snapshots and dev harness 2026-04-22 10:52:19 +07:00
Mohamed Boudra
7b9cc3d423 fix(app/e2e): use xterm buffer for alt-screen exit sync 2026-04-21 23:28:35 +07:00
github-actions[bot]
8ee3c6ae1d fix: update lockfile signatures and Nix hash 2026-04-21 16:06:25 +00:00
Mohamed Boudra
244c40a62f test(app): restore e2e playwright suite to green
- Fake gh stub now handles `gh pr view --json <fields>` by exiting 1
  with "no pull requests found for branch", matching the daemon's
  expected no-current-PR path; resolveCurrentPullRequestView no longer
  cascades failures across the suite.
- Rewrite the two post-setup script tests in workspace-setup-streaming
  to exercise explicit launch paths (UI scripts menu + daemon RPC) —
  39db3ea5 intentionally removed automatic post-setup script spawning.
- Harden terminal-alternate-screen synchronization: wait for the probe
  to observe alt-enter and alt-exit plus the final normal-screen marker
  before asserting, so the echoed command no longer races the alt-screen
  transition.
2026-04-21 23:04:57 +07:00
Mohamed Boudra
3fe63d5a2c fix(app,server): discover server-cached setup snapshots across sessions
807cce6a hides the "Show setup" action when there is no workspace setup
snapshot. That broke the case where setup genuinely ran on a different
WebSocket session than the one the browser reconnected with: the snapshot
was cached per-Session, so discovery returned null and the action stayed
hidden.

Share the setup snapshot cache at the websocket-server level and inject
it into every Session. On focus, the workspace screen now requests
fetchWorkspaceSetupStatus when it has no local snapshot; workspaces with
no setup history still return null, preserving 807cce6a's intent.
2026-04-21 23:04:47 +07:00
Mohamed Boudra
1582028902 Cache available editor discovery 2026-04-21 22:39:46 +07:00
Mohamed Boudra
7e7cc1ad56 Contain malformed ACP stdout logging 2026-04-21 22:38:12 +07:00
Mohamed Boudra
27ddfb1ea7 Fix workspace diff stats for behind branches 2026-04-21 22:03:24 +07:00
Mohamed Boudra
807cce6ad9 Hide setup action when no setup ran 2026-04-21 22:03:06 +07:00
Mohamed Boudra
73b28a7f60 Fix notification clicks preserving workspace deck 2026-04-21 21:57:15 +07:00
Mohamed Boudra
8dad423635 fix(app): remove pointer cursor on user message bubbles
The Pressable wrapping user messages is only used for hover detection,
so default to the auto cursor instead of pointer on web.
2026-04-21 21:54:45 +07:00
Mohamed Boudra
abaa29e73c ci(app): install playwright browsers before app vitest run
packages/app's vitest config has a browser project backed by playwright
+ chromium; the app-tests job needs the browsers installed the same way
the playwright job already does.
2026-04-21 21:53:23 +07:00
Mohamed Boudra
ae2a301cb6 fix(server): reflect terminalId default in workspace script schema test
workspace script schema defaults terminalId to null; include it in the
expected shape of the test's toEqual assertion.
2026-04-21 21:33:39 +07:00
Mohamed Boudra
006d57d546 fix(server): align MCP and model-resolver tests with current API
mcp-server tests invoked registered tools via .handler, but the MCP
SDK exposes the callable as .callback. model-resolver now passes
force: false to fetchModels; widen the spy matcher to match.
2026-04-21 21:29:23 +07:00
github-actions[bot]
a201e5a853 fix: update lockfile signatures and Nix hash 2026-04-21 13:50:39 +00:00
Mohamed Boudra
c402db8b1c test: add manual terminal perf harness 2026-04-21 20:47:28 +07:00
Mohamed Boudra
6a03873d81 fix: smooth terminal streaming 2026-04-21 20:47:17 +07:00
Mohamed Boudra
39db3ea5ff refactor: simplify worktree script spawning 2026-04-21 20:46:54 +07:00
Mohamed Boudra
79bf5ac3c7 test: add app browser coverage 2026-04-21 20:46:49 +07:00
Mohamed Boudra
051507717c Fix sticky workspace hover card 2026-04-21 18:17:39 +07:00
Mohamed Boudra
f3cb19529c Harden agent refresh rescue path 2026-04-21 17:22:50 +07:00
Mohamed Boudra
cea97c83b3 Reuse terminals for non-service scripts and track lifecycle via OSC 633
Scripts launched from the UI now run in a dedicated terminal per script
(titled after the script) and reuse the same terminal across re-runs.
Script lifecycle is driven by a new onCommandFinished signal sourced from
OSC 633 command-finished events emitted by the zsh shell integration, so
foreground script exit updates status without killing the terminal.
Script payloads now carry terminalId so the workspace can surface the
associated tab from the scripts dropdown.
2026-04-21 17:12:53 +07:00
Mohamed Boudra
3e2faab7f8 Fix committed diff excluding dirty worktree 2026-04-21 15:02:20 +07:00
Mohamed Boudra
ebf8a21c08 Fix TTLCache import and rename schedule provider resolver 2026-04-21 14:19:51 +07:00
Mohamed Boudra
515ba62be6 Persist sidebar collapse state and stabilize Metro port 2026-04-21 14:01:16 +07:00
Mohamed Boudra
32a20cbeea Merge branch 'research/mcp-list-agents-summary' 2026-04-21 13:46:25 +07:00
Mohamed Boudra
9877936d13 feat: require provider/model format and return compact list items 2026-04-21 13:44:55 +07:00
Mohamed Boudra
1a473ed7f3 Persist composer preferences and stabilize hydration 2026-04-21 13:43:22 +07:00
Mohamed Boudra
3832053736 fix: provider-scoped snapshot refresh with self-healing invalidation 2026-04-21 13:14:59 +07:00
Mohamed Boudra
b01e274cce Fix provider refresh scope and self-healing 2026-04-21 12:41:28 +07:00
Mohamed Boudra
92e701be8f style: adjust typography and standardize ui text 2026-04-20 21:31:59 +07:00
Mohamed Boudra
c2c990e763 docs: note beta rename in changelog 2026-04-20 20:49:38 +07:00
Mohamed Boudra
76b9f89f68 fix: tolerate beta release creation races 2026-04-20 20:41:24 +07:00
github-actions[bot]
970bf828b3 fix: update lockfile signatures and Nix hash 2026-04-20 13:40:07 +00:00
Mohamed Boudra
dfc814147a feat: add beta release channel 2026-04-20 20:38:56 +07:00
Mohamed Boudra
831d990ef7 fix(release): avoid notes sync create races 2026-04-20 19:37:46 +07:00
Mohamed Boudra
e09fd41adc fix(release): patch existing release notes by id 2026-04-20 19:33:39 +07:00
github-actions[bot]
8d32167826 fix: update lockfile signatures and Nix hash 2026-04-20 12:29:27 +00:00
Mohamed Boudra
1cfae98bf5 chore(release): cut 0.1.60-rc.3 2026-04-20 19:28:23 +07:00
Mohamed Boudra
4cbfe95473 fix(server): treat pi aborts as cancellations 2026-04-20 19:02:37 +07:00
Mohamed Boudra
242f61937a fix(app): stabilize terminal creation handler 2026-04-20 19:02:34 +07:00
Mohamed Boudra
d913c4a9b7 fix(app): isolate bottom sheet modal scopes 2026-04-20 19:02:31 +07:00
Mohamed Boudra
2cd296e38f Fix workspace shortcut navigation 2026-04-20 18:06:09 +07:00
Mohamed Boudra
95c1d1fe0f Fix workspace composer focus routing 2026-04-20 17:41:00 +07:00
Mohamed Boudra
33ac657fed fix(app): treat workspace diff stats as authoritative 2026-04-20 17:40:11 +07:00
Mohamed Boudra
067125bd4f Fix composer safe-area ownership 2026-04-20 17:35:20 +07:00
Mohamed Boudra
93095aa1d2 Fix dictation readiness after app refresh 2026-04-20 17:23:17 +07:00
Mohamed Boudra
ac45f23213 Fix mobile sidebar regressions and workspace e2e 2026-04-20 16:38:12 +07:00
github-actions[bot]
b91749a316 fix: update lockfile signatures and Nix hash 2026-04-20 09:15:24 +00:00
Mohamed Boudra
a6423f7f0a Improve workspace navigation and render isolation 2026-04-20 16:14:22 +07:00
Mohamed Boudra
403fb03c97 feat(app): retain workspace screens on sidebar switch 2026-04-20 12:55:58 +07:00
Mohamed Boudra
f8411d121e Separate explorer checkout from panel state 2026-04-20 12:40:39 +07:00
Mohamed Boudra
9ea75afd37 Fix welcome screen unistyles leak 2026-04-20 12:40:30 +07:00
Mohamed Boudra
006894274e fix(desktop): patch React DevTools manifest for Electron compatibility 2026-04-20 11:16:21 +07:00
Mohamed Boudra
951fadc104 fix(mcp): accept null in list_models output schema 2026-04-20 11:06:21 +07:00
Mohamed Boudra
8600f183b1 feat(sidebar): restore PR state color, swap badge order, brighten checks on hover
- PR icon color reflects merge state (open/merged/closed) via the
  existing getWorkspacePrIconColor helper.
- PR badge is rendered before the failed-checks badge in the workspace
  row.
- Hovering the PR badge swaps the GitPullRequest icon for ExternalLink
  in place (same size, no layout shift) and removes the trailing arrow.
- Hovering the checks row in the workspace hover card brightens the
  icon and "Checks" label to foreground.
2026-04-20 10:57:27 +07:00
Mohamed Boudra
002946d4dd fix(sidebar): prevent text selection when dragging project/workspace rows
The drag handle was attached only to the left sub-view of each row, so
grabbing the right half initiated no drag, and pointerdown on the branch
or project title raced the browser's text-selection start before
dnd-kit's activation threshold — often producing a text selection
instead of a drag.

Move dragHandleProps to the outer row wrapper, add userSelect: none to
the row styles, and tighten PointerSensor activation distance.
2026-04-20 10:49:46 +07:00
Mohamed Boudra
fb02bc8795 fix(workspace): collapse header loading skeleton to a single bar
The header now shows only one row of text, so drop the second skeleton
and widen the primary bar to match.
2026-04-20 10:46:23 +07:00
Mohamed Boudra
53baa16bee refactor(checkout-diff): rely solely on subscription for data
Drop the one-shot getCheckoutDiff query and use skipToken; the
subscribe_checkout_diff flow populates the cache on subscribe response
and updates. Derive isLoading from the presence of cached payload and
remove the now-unused isError/error fields from git-diff-pane.
2026-04-20 10:45:59 +07:00
Mohamed Boudra
67eddbd6a9 perf(explorer-sidebar): memoize animation context value
Stabilize animateToOpen/animateToClose with useCallback and wrap the
provider value in useMemo so consumers don't re-render on every parent
re-render.
2026-04-20 10:45:55 +07:00
Mohamed Boudra
044546ae0c perf(explorer-sidebar): skip PR queries when sidebar is closed
Thread isOpen down to SidebarContent so usePrPaneData stays disabled
until the user actually opens the sidebar.
2026-04-20 10:45:51 +07:00
Mohamed Boudra
bd748b6edc perf(workspace): gate side effects on stack route focus
Pass useIsFocused through as isRouteFocused rather than short-circuiting
the whole tree. Terminals query/subscription, checkout diff, providers
snapshot, keybindings, tab reconciliation, focused-agent writes, and
sidebar rendering all no-op when the route is in the stack but not
focused, so a hidden workspace screen no longer drives background work.
2026-04-20 10:45:48 +07:00
Mohamed Boudra
427664dfa7 fix(app): drop getId from workspace stack route
Expo Router maps getId to React Navigation getId, which reorders an
already-mounted workspace screen on Android native-stack/Fabric. Keep
workspace identity/retention outside the route-level API and leave a
comment warning against re-adding it.
2026-04-20 10:45:02 +07:00
Mohamed Boudra
a0169bf7f4 feat(app): export isDev platform flag and enable runtime metrics in dev
Adds a shared isDev constant alongside isWeb/isNative and wires daemon
clients to collect runtime metrics (10s interval) only in development.
2026-04-20 10:44:57 +07:00
Mohamed Boudra
79968ee02d feat(daemon-client): add opt-in runtime metrics collector
Aggregates inbound message counts, bytes, handler timings, and
agent_stream breakdowns over a rolling window and flushes via logger.
Gated on runtimeMetricsIntervalMs config; disabled by default.
2026-04-20 10:44:43 +07:00
Mohamed Boudra
afe0eaab5b Set APP_VARIANT=development in app startup scripts 2026-04-20 09:07:34 +07:00
Mohamed Boudra
04465f124c Add dev mock load-test provider 2026-04-20 00:38:10 +07:00
Mohamed Boudra
86b83c3366 Fix workspace diff stat watch refresh 2026-04-20 00:36:54 +07:00
github-actions[bot]
4a5dd986e1 fix: update lockfile signatures and Nix hash 2026-04-19 16:23:48 +00:00
Mohamed Boudra
64065061c5 chore(release): cut 0.1.60-rc.2 2026-04-19 23:21:12 +07:00
Mohamed Boudra
7b9636a22d fix(server): bypass diff cache on working-tree watch refresh
CheckoutDiffManager.refreshTarget was calling workspaceGitService.getCheckoutDiff
without forcing a cache bypass, so watch-fired refreshes within the 15s consumer
TTL returned the pre-mutation cached diff — fingerprint matched, no
checkout_diff_update was emitted, and the sidebar diff appeared frozen while an
agent edited files. Route watch-fired refreshes through the service with
{ force: true, reason: "working-tree-watch" } so they always recompute.
2026-04-19 23:19:41 +07:00
Mohamed Boudra
8165de7b06 feat(server): replay pi tool calls in streamHistory for resume
Pi's streamHistory only emitted text, so resuming a session lost tool
calls, tool results, and bash executions from the replayed timeline.
Iterate session.messages to also emit running/completed/failed tool_call
items and bashExecution shell calls, and tag user messages with a stable
messageId so hydrateTimelineFromLegacyProviderHistory can dedupe.

Cover it with a real OpenRouter-backed e2e test that runs a bash tool
call, resumes via PiDirectAgentClient.resumeSession, and asserts the
replayed timeline contains the user message, assistant message, and
completed shell tool_call with the expected output.
2026-04-19 23:17:40 +07:00
Mohamed Boudra
75f83b0b9b Merge refactor/session-store-identity into main 2026-04-19 22:36:10 +07:00
Mohamed Boudra
badec6f587 Merge orchestrate/notif-routing-presence into main 2026-04-19 22:35:37 +07:00
Mohamed Boudra
5a2708653c feat(server,app): redesign git/GitHub refresh architecture with server authority
Centralizes refresh orchestration in WorkspaceGitService and GitHubService:
server coalesces, deduplicates, and throttles git/GitHub reads; clients peek
snapshots (cold reads fall through to a one-shot fetch) and never drive
refreshes. Introduces a two-TTL model (consumer vs internal), force-emit
bypass so fingerprint-matched refreshes still propagate, notifyGitMutation
wiring at ~13 action handlers, adaptive GitHub polling based on check state,
and checkout_status_update / prStatus push-to-cache wire messages. Fixes the
stale diff-stat bug and the inverted base-branch diff direction, and flips
next-action affordances promptly after commit / PR merge.
2026-04-19 22:35:26 +07:00
Mohamed Boudra
06b3fc2ec6 fix(server,app,desktop): route in-app notifications by presence cascade
Replaces the per-client stale/visibility policy with one shared
presence cascade so a backgrounded Firefox tab can no longer steal
in-app notifications from an active Electron desktop window.

Server (`agent-attention-policy.ts`): exports `PRESENCE_THRESHOLD_MS`
(3 min) and `computeNotificationPlan({ allStates, agentId, reason,
nowMs })` returning `{ inAppRecipientIndex, shouldPush }`. Cascade:
focus suppression requires presence; otherwise the most-recent
present client wins (lower-index tiebreak); push fires only when no
client is present, and never on `error`. `broadcastAgentAttention`
calls the policy once per event.

Electron (`daemon-manager.ts`, `idle.ts`): registers
`desktop_get_system_idle_time` returning `powerMonitor
.getSystemIdleTime() * 1000`. Renderer wrapper
`getDesktopSystemIdleTimeMs()` is best-effort: rejected IPC, null,
NaN, Infinity, or negative values return null without throwing.

Hook (`use-client-activity.ts`): Electron-only effect polls every 5s
and updates `lastActivityAtRef` only forward, so OS-wide activity
keeps a backgrounded Electron window present while in-window events
still update immediately.

`ClientHeartbeatMessageSchema` is unchanged; old/new clients and
daemons remain wire-compatible.

Tests: 52 focused tests across policy unit, dispatch, e2e, hook,
desktop wrapper, and permissions. Phase-3 dispatch test +
Phase-5 stale-poll test verified by mutation (breaking the focus
check / the `>` guard makes the exact regression fail).
2026-04-19 21:48:39 +07:00
Mohamed Boudra
bd1fb311ea refactor(app): reshape session store reads around narrow hook surface
Eliminate sidebar re-render cascades by fixing both sides of the
subscription contract:

- Session-store writes preserve identity: setWorkspaces, mergeWorkspaces,
  removeWorkspace, and patchWorkspaceScripts return the previous Map/entry
  reference when content is unchanged.
- HostRuntimeController.updateSnapshot only notifies when a patched field
  actually changed; idle probe ticks no longer replace the snapshot.
- New canonical hook surface in stores/session-store-hooks.ts
  (useWorkspace, useWorkspaceFields, useWorkspaceStructure,
  useWorkspaceKeys, useResolveWorkspaceIdByCwd,
  useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority,
  useRecommendedProjectPaths, useHasWorkspaces). These are the only
  place to subscribe to workspaces.
- 15 greedy subscribers migrated to the hook surface. The sidebar is now
  driven by useWorkspaceStructure for identity/ordering; each row
  hydrates its own descriptor via useWorkspaceFields, so a workspace
  update re-renders only its row. Archive-redirect callbacks switched to
  event-time useSessionStore.getState().
- Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts,
  and the unused SidebarProjectEntry aggregates (activeCount,
  totalWorkspaces, aggregated statusBucket).

No user-visible behavior change. Targeted tests green (79 tests across
host-runtime, session-store, session-store-hooks, sidebar list, sidebar
row model, sidebar shortcuts, workspace source of truth).
2026-04-19 20:19:45 +07:00
Mohamed Boudra
8991d34f78 fix(app): allow collapsing the parent folder of a selected file (#500)
The file explorer had a useEffect that re-enforced expansion of every
ancestor of `selectedEntryPath` whenever `expandedPaths` changed. As a
result, the moment a user collapsed the parent folder of a file they
had just opened, the effect re-added that folder to the expanded set
and the folder appeared stuck open.

Selection and expansion are orthogonal: clicking a file should not
dictate that its parent folder stays open forever. There is also no
current caller that sets `selectedEntryPath` from outside the tree, so
the reveal-on-external-selection use case the effect was designed for
is unused. Delete the effect and its two now-dead helpers. If a future
"Reveal in Explorer" action is introduced, it can expand ancestors at
the call site.

Adds an E2E regression test that reproduces the original scenario:
expand a folder, open an image, collapse the parent folder, assert
children are hidden. Also asserts an unrelated sibling folder still
expands after the image is open, guarding against any future regression
that makes the bug global.
2026-04-19 16:49:09 +08:00
Mohamed Boudra
128d6668ae fix(server): update PR status test for new normalized fields
normalizeCheckoutPrStatusPayload always emits the full wire shape now
(number, repoOwner, repoName, isDraft, checks, checksStatus,
reviewDecision) — the workspace-git-watch test's toEqual assertion
still had the pre-PR-pane shape and was failing.

Update the expected object to match the actual wire contract.
2026-04-19 15:11:37 +07:00
Mohamed Boudra
d9d6509880 fix(test): resolve Metro .web.ts variants in vitest
message-input.tsx imports ./composer-height-mirror, which only exists
as .web.ts / .native.ts / .d.ts. Metro picks the right variant at build
time, but vitest was using Vite's default extension list and failing to
resolve the import — breaking app tests in CI.

Prepend `.web.*` variants to `resolve.extensions` in both the root and
app vitest configs so test runs (which already alias react-native →
react-native-web) pick the web implementation, matching what Metro
does for the web bundle.
2026-04-19 15:11:37 +07:00
Mohamed Boudra
2d9cbb0453 fix(server): resolve remote-only branches in switcher checkout
Branch switcher listed remote-only branches as if they were local, then
threw "Branch not found" when clicked because the checkout path only
verified `refs/heads/<name>`. Explicit `origin/<name>` input went the
other way — verify passed, but checkout produced a detached HEAD.

Split the concerns into three layers:

- Enumerate: `listBranchSuggestions` returns provenance (`hasLocal`,
  `hasRemote`) so the wire payload no longer loses origin-only info.
  Fields are optional on `branchDetails` for backward compat.
- Resolve: new `resolveBranchCheckout` normalizes `origin/<name>` →
  `<name>`, verifies explicit `refs/heads/` and `refs/remotes/origin/`
  paths (no tag-DWIM), returns a tagged
  `{ local | remote-only | not-found }` result. Policy (local wins,
  origin fallback) lives here.
- Act: `checkoutExistingBranch` no longer does implicit DWIM. Local →
  `git checkout <name>`. Remote-only → `git checkout -b <name> --track
  origin/<name>`. Not-found → throws the existing
  `Branch not found: <name>` string. Response carries optional
  `source: "local" | "remote"`.

`validateBranch` now routes through the same resolver so both paths
agree on what a branch is.

Also fixes a related stale-base diff bug: worktrees created off
`origin/main` could show phantom "added" commits when the local `main`
lagged. `resolveBestComparisonBaseRef` is now origin-first for
diff/status. Merge still uses the "most-ahead" helper so unpublished
local commits aren't lost. `doesGitRefExist` tightened to only swallow
exit 1 (missing ref), propagating other git failures.
2026-04-19 14:26:52 +07:00
Mohamed Boudra
e373c33108 feat(app,server): add pull request pane to the workspace sidebar
Introduce a PR tab in the explorer sidebar that shows status checks,
reviews, and the merged timeline for the current branch's pull
request. Extend checkout PR status with number, draft flag, repo
identity, and per-check workflow/duration; add a new
pull_request_timeline request/response pair backed by a GraphQL
query; enrich the workspace git snapshot to carry the fields through
to the client; and extend the checkout-git-actions store so timeline
queries are invalidated alongside status when mutations land.
2026-04-19 14:26:52 +07:00
Mohamed Boudra
6a36db9924 feat(server): invalidate GitHub cache after local git mutations
Pass the GitHub service into pullCurrentBranch, pushCurrentBranch,
createPullRequest, branch setup, and worktree archival, and invalidate
the cached PR status / repo view whenever a mutation succeeds. Have
mergeToBase return the worktree cwd it actually touched so the session
can invalidate the base checkout when the merge lands there.
2026-04-19 14:26:51 +07:00
Mohamed Boudra
d3393b434a feat(app,server): rank new-workspace picker by recency with debounced search
Plumb updatedAt through GitHub issue/PR search and committerDate through
branch suggestions so the new-workspace picker interleaves branches and
PRs sorted by last activity. Debounce the search query so remote GitHub
searches are driven by what the user typed rather than a static list.
2026-04-19 14:26:51 +07:00
Mohamed Boudra
43bbf8cb12 feat(app): collapse workspace header button labels on narrow rows
Measure the header row width and hide text labels on the Open,
Scripts, and git action split buttons below 700px so the header
fits without wrapping on split-pane layouts.
2026-04-19 14:26:51 +07:00
Mohamed Boudra
cfa3b009e3 fix(app): prevent branch switcher title from overflowing its row
Wrap the branch icon and title in a flex row with minWidth 0 and
overflow hidden so long titles truncate instead of pushing the
switcher off-screen.
2026-04-19 14:26:51 +07:00
Mohamed Boudra
f411a2a383 feat(app): rank combobox options by word-boundary match quality
Introduce a shared scoreMatch utility that tiers matches by
word-boundary vs mid-word offset, and use it to rank combobox
options so e.g. "py" prefers "a/py" over "happy".
2026-04-19 14:26:51 +07:00
Mohamed Boudra
c4d84ff2a3 chore: add workspace aliases to vitest config for app tests
Resolve @, @server, @getpaseo/relay, react, react-native, and xterm
ligatures from the app workspace so cross-workspace tests run.
2026-04-19 14:26:51 +07:00
Mohamed Boudra
73b0b60bdd fix(server): block dev-runner on Ctrl-C until supervisor finishes shutting down
The spawnSync parent had no signal handlers, so SIGINT killed it immediately
while the supervisor kept running in the background. The shell prompt came
back before the daemon had actually drained. Install no-op SIGINT/SIGTERM
handlers so spawnSync waits for the supervisor's graceful exit.
2026-04-19 14:26:51 +07:00
Mohamed Boudra
5a232d8cf1 fix(server,app): coalesce tool call stream events to prevent JS thread freeze
Pi's ACP binary streams 200+ redundant tool_call_update events per tool call,
bypassing the server coalescer and flooding the app with synchronous state
updates. Two-layer fix:

Server: extend AgentStreamCoalescer to handle tool_call items via last-write-wins
per callId (200ms window, up from 33ms). Terminal statuses flush immediately.

App: mergeToolCallDetail and appendAgentToolCall now return existing references
when fields are unchanged, preserving React.memo equality checks.
2026-04-19 14:26:50 +07:00
Mohamed Boudra
2238171955 fix(app): composer textarea shrinks back down on web
Measuring scrollHeight on the live textarea with an explicit height
set can only grow — it can never report a shorter content height.
The old `height: auto` toggle flickered across browsers.

Use a hidden off-DOM textarea that mirrors the composer's text-metric
styles and width, and read its scrollHeight on value/resize changes.
Gated by Metro file extension (.web / .native) so it's trivial to
rip out later.
2026-04-19 14:26:50 +07:00
Mohamed Boudra
fbc2371b95 feat(server): replace Pi ACP with direct SDK provider (#202)
* feat(server): replace Pi ACP integration with direct SDK provider

Replace the ACP-based Pi provider (which spawned a `pi-acp` subprocess
and talked JSON-RPC over stdio) with a direct in-process integration
using `@mariozechner/pi-coding-agent` as a library.

The new `PiDirectAgentClient` and `PiDirectAgentSession` use the Pi SDK
directly — creating sessions via `createAgentSession()`, managing models
via `ModelRegistry`, and mapping Pi's `AgentSessionEvent` stream to
Paseo's `AgentStreamEvent` types including:

- Thread/turn lifecycle events
- Assistant text and thinking/reasoning deltas
- Tool execution (bash, read, write, edit, find, grep, ls) with arg
  caching across start/update/end events
- Compaction events
- Session persistence and resume via `SessionManager`

Thinking levels flow through the existing `thinkingOptionId` /
`setThinkingOption()` infrastructure, not through features.

Includes 9 real e2e tests against a live Pi agent covering tool calls
(bash/read/write/edit), reasoning chunks, session persistence/resume,
model listing, and thinking option management.

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

* fix: update lockfile signatures and Nix hash

* refactor(server): improve Pi direct provider code quality

Address coding standards violations in pi-direct-agent.ts:

- Replace all `as Record<string, unknown>` / `as any` / `as unknown as`
  casts with properly typed boundaries using Pi SDK's exported tool
  input types (BashToolInput, ReadToolInput, EditToolInput, etc.)
- Introduce named interfaces at all boundaries: PiPromptPayload,
  ToolCallOutputSummary, PiModelReference, PiPersistenceMetadata
- Parse tool args once at boundary into typed discriminated union,
  then trust types internally — no more typeof probing in mappers
- Break all dense ternary chains into named steps / helper functions
- Import SDK types directly (RegisteredCommand, ResourceLoader, Skill)
  instead of casting through anonymous inline shapes
- Isolate the system prompt SDK escape hatch into a minimal boundary
  helper with a named interface

Behavior is preserved — all 9 real e2e tests still pass.

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

* fix: regenerate package-lock.json with resolved/integrity fields

The previous lockfile regeneration ran under an offline/cached npm
context and stripped resolved URLs and integrity hashes from existing
entries, which would break npm ci and Nix builds.

* fix: update Nix npmDepsHash for regenerated lockfile

* chore(server): bump @mariozechner/pi-coding-agent to ^0.67.68

* fix: update lockfile signatures and Nix hash

* fix(app): surface agent setter RPC errors as toasts

setAgentMode/Model/ThinkingOption/Feature errors from the daemon were
only logged to console, so failures like missing API keys silently
vanished. Add toast.error fallbacks at all call sites and hoist
ToastProvider above SessionProvider so session context can use it.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-19 14:45:01 +08:00
Mohamed Boudra
f8815bf1c2 docs(website): rewrite worktrees page around scripts, services, and proxy 2026-04-18 21:15:06 +07:00
github-actions[bot]
93cda8e60f fix: update lockfile signatures and Nix hash 2026-04-18 13:58:06 +00:00
Mohamed Boudra
c53d42acf8 chore(release): cut 0.1.60-rc.1 2026-04-18 20:56:49 +07:00
github-actions[bot]
2a519d96e5 fix: update lockfile signatures and Nix hash 2026-04-18 13:54:47 +00:00
Mohamed Boudra
07714773cc Revert "chore(release): cut 0.2.0-rc.1"
This reverts commit 9fb0ab9b2d.
2026-04-18 20:53:24 +07:00
github-actions[bot]
ada623c1c6 fix: update lockfile signatures and Nix hash 2026-04-18 13:47:19 +00:00
Mohamed Boudra
9fb0ab9b2d chore(release): cut 0.2.0-rc.1 2026-04-18 20:46:06 +07:00
Mohamed Boudra
83bbc8f659 Merge branch 'dev' into main 2026-04-18 19:54:47 +07:00
Mohamed Boudra
7819019cea feat(server,app,website): prefix service URLs with project slug
Service hostnames now follow DNS convention with a project label so
independently opened projects no longer collide on routes like
`web.localhost`. Format becomes `{script}.{project}.localhost` for
default branches and `{script}.{branch}.{project}.localhost` for other
branches. The project slug comes from the GitHub repository name when a
remote exists, otherwise from the workspace directory basename.

Server changes:
- `buildScriptHostname` takes `{ projectSlug, branchName, scriptName }`
  and slugifies all labels at the boundary; `untitled` fallback only
  applies at hostname-label sites, never to shared `slugify`.
- `deriveProjectSlug(cwd)` and `parseGitHubRepoNameFromRemote` derive
  the slug; GitHub URL parsing now requires `host === "github.com"` to
  avoid embedded-path false matches.
- `ScriptRouteEntry` carries `projectSlug`; branch-rename handler reuses
  it instead of re-reading git in hot paths.
- Status projection derives slug once per call and uses route hostname
  when available; only falls back to building when no route exists.
- Spawn cleanup removes routes by `{ workspaceId, scriptName }` so
  cleanup after a branch rename doesn't leave a stale entry behind.

App and shared message tests updated to the new fixture shape; service
URLs continue to be opaque server-provided strings.

Landing-page service URL examples updated; allowlist docs unchanged.
2026-04-18 19:31:23 +07:00
Mohamed Boudra
23b20169ef test(app,server): align e2e specs with settings redesign, drop rg dep
- worktree-core test scans the server source via a pure Node walker
  instead of shelling out to `rg`, which is not installed on CI runners.
  Invariant and scope are unchanged.
- settings-host-page specs target the new detail-header label + rename
  affordance (settings-detail-header-title / host-page-label-edit-button)
  since the label card moved into the shared header as part of the
  modular settings refactor.
- settings-navigation compact tests assert the new two-tier Back
  semantics: root Back exits settings, detail Back returns to /settings.
2026-04-18 18:07:07 +07:00
Mohamed Boudra
e5b0ea6d1b fix(paseo): pin app service to PASEO_PORT 2026-04-18 17:19:44 +07:00
Mohamed Boudra
4de4e90325 fix(app): close theme split escape hatches 2026-04-18 16:25:23 +07:00
Mohamed Boudra
fa41ba76cd Merge branch 'orchestrate/agent-stream-coalescing' into dev 2026-04-18 16:09:05 +07:00
Mohamed Boudra
9f6570ca3e Merge branch 'config-work' into dev 2026-04-18 16:08:57 +07:00
Mohamed Boudra
ccb956f98f wip(server): coalesce agent stream events and add runtime metrics
Introduce AgentStreamCoalescer to batch rapid-fire agent events before
broadcasting, reducing WebSocket chatter during tool-heavy turns. Wire
websocket-server runtime metrics so we can observe coalescing behavior
in tests. Work in progress.
2026-04-18 16:08:54 +07:00
Mohamed Boudra
f483e62231 wip(app): keep keyboard shortcuts alive on /settings
Chrome gating currently disables shortcuts on non-workspace routes. Temporary
pathname check until chromeEnabled is split into separate workspace/global
concerns.
2026-04-18 16:08:43 +07:00
Mohamed Boudra
c0bb34c514 docs: document service-script env vars and split dev into daemon+app
Document the PASEO_SERVICE_<NAME>_URL/PORT and PASEO_URL/PORT contract exposed
by the service-script runtime. Split paseo.json's monolithic "server" script
into "daemon" and "app" services so peer discovery exercises the new env.
2026-04-18 16:03:27 +07:00
Mohamed Boudra
6b5282ad43 refactor(server): extract workspace service env and port registry
Pull service env construction and port allocation out of worktree-bootstrap
into dedicated modules with their own tests, so the bootstrap path only
orchestrates. Adds peer collision detection and a port plan that is
computed once and reused across scripts.
2026-04-18 16:03:18 +07:00
Mohamed Boudra
72e4476acc refactor(app): replace providers-snapshot invalidate with refetchIfStale
invalidate() flagged the query stale and kicked off a refetch unconditionally,
causing a loading flash every time the model selector opened. Expose
refetchIfStale() instead so callers only refetch when data is actually stale.
2026-04-18 16:03:13 +07:00
Mohamed Boudra
5d76cc8840 feat(app): gate return-key submit icon to new workspace composer
The new-workspace composer is a one-shot "create" submit, not a chat send, so
a return-key glyph reads more accurately there than an arrow. Add a submitIcon
prop on Composer/MessageInput (defaulting to "arrow") and opt NewWorkspace
into "return".
2026-04-18 16:03:08 +07:00
Mohamed Boudra
b0e0786419 fix(app): make cmd+o work regardless of sidebar visibility
The worktree.new keyboard handler was registered inside ProjectHeaderRow,
which only mounts when the sidebar is open. Register a single global handler
in the app layout keyed off the navigation-active workspace selection.
2026-04-18 16:03:01 +07:00
Mohamed Boudra
14cc1cc83c feat: support creating worktrees from GitHub pull requests 2026-04-18 12:08:30 +07:00
Mohamed Boudra
82f97e942d fix(server): suppress browser popups from ACP probes
Pass NO_BROWSER=true when probing ACP agents for models, modes, and
persisted sessions. Gemini CLI's ACP newSession handler calls refreshAuth
inline and will open a Google OAuth URL in the browser when no valid
cached token is present — every snapshot warm-up triggered this on
navigation. Real agent sessions still use their own launchContext.env,
so interactive auth continues to work when actually starting an agent.
2026-04-18 11:10:34 +07:00
Mohamed Boudra
59d9b2ebdd fix(app): bridge trigger and content in workspace hover card
Closes the hover card via a safe-zone hook that covers the trigger, the
content, and the rectangular bridge between them, so crossing the visual
gap no longer drops the hover. Replaces the event-based close flow whose
dropped pointerleave events left cards stuck open.
2026-04-18 10:58:17 +07:00
Mohamed Boudra
d8ff049aff fix(app): only open tooltip on keyboard focus
Modal close restores focus to the tooltip's trigger, which was firing
onFocus and re-opening the tooltip. Gate onFocus on input modality so
only keyboard-driven focus opens it, matching W3C tooltip behavior.
Also removes the key-based remount workarounds that existed solely to
sidestep this bug.
2026-04-18 10:10:38 +07:00
Mohamed Boudra
f08bff1b2d fix(app): centralize workspace diff stat rendering
The source-control button rendered diff stats inline with no formatting
while the sidebar used toLocaleString, so identical numbers looked
different. Route both through DiffStat and switch to compact notation
(e.g. 87,700 -> 87.7k) so large counts stay readable.
2026-04-18 09:56:14 +07:00
1209 changed files with 166401 additions and 73064 deletions

View File

@@ -51,7 +51,7 @@ jobs:
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--generate-notes
--notes ""
)
if [[ "$IS_PRERELEASE" == "true" ]]; then

View File

@@ -15,14 +15,30 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Check formatting
run: npx biome format .
run: npx oxfmt --check .
lint:
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 install
- name: Lint
run: npm run lint
typecheck:
runs-on: ubuntu-latest
@@ -31,8 +47,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -58,8 +74,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
@@ -88,8 +104,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -106,9 +122,12 @@ jobs:
npx vitest run
src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.percent-escape.test.ts
src/utils/spawn.test.ts
src/utils/process-tree.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts
src/terminal/worker-terminal-manager.test.ts
src/server/agent/provider-registry.test.ts
src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts
@@ -119,6 +138,35 @@ jobs:
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
desktop-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- 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: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop
app-tests:
runs-on: ubuntu-latest
steps:
@@ -126,12 +174,15 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
@@ -145,8 +196,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -188,8 +239,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -207,8 +258,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -222,6 +273,6 @@ jobs:
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
PASEO_DICTATION_ENABLED: '0'
PASEO_VOICE_MODE_ENABLED: '0'
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"

View File

@@ -3,10 +3,10 @@ name: Deploy App
on:
push:
tags:
- 'v*'
- '!v*-rc.*'
- 'app-v*'
- '!app-v*-rc.*'
- "v*"
- "!v*-beta.*"
- "app-v*"
- "!app-v*-beta.*"
workflow_dispatch:
jobs:
@@ -18,13 +18,13 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install dependencies
run: npm install --workspace=@getpaseo/app --include-workspace-root
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,8 +4,8 @@ on:
push:
branches: [main]
paths:
- 'packages/relay/**'
- '.github/workflows/deploy-relay.yml'
- "packages/relay/**"
- ".github/workflows/deploy-relay.yml"
workflow_dispatch:
jobs:
@@ -17,8 +17,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install --workspace=@getpaseo/relay --include-workspace-root
@@ -30,4 +30,3 @@ jobs:
run: cd packages/relay && npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -4,13 +4,14 @@ on:
push:
branches: [main]
paths:
- 'packages/website/**'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
- "CHANGELOG.md"
- "packages/website/**"
- "package.json"
- "package-lock.json"
- "patches/**"
- ".github/workflows/deploy-website.yml"
release:
types: [published, edited]
types: [published]
workflow_dispatch:
jobs:
@@ -23,8 +24,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install --workspace=@getpaseo/website --include-workspace-root

View File

@@ -3,27 +3,45 @@ name: Desktop Release
on:
push:
tags:
- 'v*'
- 'desktop-v*'
- 'desktop-macos-v*'
- 'desktop-linux-v*'
- 'desktop-windows-v*'
- "v*"
- "desktop-v*"
- "desktop-macos-v*"
- "desktop-linux-v*"
- "desktop-windows-v*"
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to build (e.g. v0.1.0)'
description: "Existing tag to build (e.g. v0.1.0)"
required: true
type: string
platform:
description: 'Optional desktop platform to build.'
description: "Optional desktop platform to build."
required: false
default: 'all'
default: "all"
type: choice
options:
- all
- macos
- linux
- windows
checkout_ref:
description: "Optional branch/ref to checkout while using tag for release metadata."
required: false
default: ""
type: string
publish:
description: "Publish built artifacts to GitHub Releases."
required: false
default: "true"
type: choice
options:
- "true"
- "false"
rollout_hours:
description: "Linear rollout duration in hours. Use 0 for instant rollout."
required: false
default: "24"
type: string
concurrency:
group: desktop-release-${{ github.ref }}
@@ -31,12 +49,14 @@ concurrency:
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
DESKTOP_WORKSPACE: '@getpaseo/desktop'
DESKTOP_PACKAGE_PATH: 'packages/desktop'
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
DESKTOP_PACKAGE_PATH: "packages/desktop"
jobs:
create-release:
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all' && github.event.inputs.publish != 'false') }}
permissions:
contents: write
runs-on: ubuntu-latest
@@ -44,7 +64,7 @@ jobs:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release metadata
shell: bash
@@ -65,8 +85,10 @@ jobs:
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--generate-notes \
$prerelease_flag
--notes "" \
$prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
}
fi
publish-macos:
@@ -89,7 +111,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release metadata
shell: bash
@@ -98,11 +120,11 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
@@ -125,9 +147,6 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Build desktop release
shell: bash
env:
@@ -138,121 +157,40 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PASEO_DESKTOP_SMOKE: "1"
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload manifest artifact
if: env.IS_SMOKE_TAG != 'true'
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
retention-days: 1
finalize-mac-manifest:
needs: [publish-macos]
if: ${{ needs.publish-macos.result == 'success' }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
- name: Merge manifests
if: env.IS_SMOKE_TAG != 'true'
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs');
// Simple YAML parser for electron-builder's latest-mac.yml format
function parseManifest(text) {
const lines = text.split('\n');
const result = { files: [] };
let currentFile = null;
for (const line of lines) {
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
else if (line.trim().startsWith('- url:')) {
currentFile = { url: line.trim().replace('- url: ', '') };
result.files.push(currentFile);
} else if (line.trim().startsWith('sha512:') && currentFile) {
currentFile.sha512 = line.trim().split(': ')[1].trim();
} else if (line.trim().startsWith('size:') && currentFile) {
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
currentFile = null;
}
}
return result;
}
function toYaml(manifest) {
let out = `version: ${manifest.version}\n`;
out += `files:\n`;
for (const f of manifest.files) {
out += ` - url: ${f.url}\n`;
out += ` sha512: ${f.sha512}\n`;
out += ` size: ${f.size}\n`;
}
out += `path: ${manifest.path}\n`;
out += `sha512: ${manifest.sha512}\n`;
out += `releaseDate: '${manifest.releaseDate}'\n`;
return out;
}
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
// Merge: all files from both, default path points to arm64 zip
const merged = {
version: arm64.version,
files: [...arm64.files, ...x64.files],
path: arm64.path,
sha512: arm64.sha512,
releaseDate: arm64.releaseDate || x64.releaseDate,
};
const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output);
console.log('Merged manifest:\n' + output);
NODE
- name: Upload merged manifest to release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
publish-linux:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
@@ -265,7 +203,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release metadata
shell: bash
@@ -274,11 +212,11 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
@@ -301,24 +239,47 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Install Linux smoke display
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
build_args=(-- --publish never --linux --x64)
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: linux-manifest
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-linux.yml
retention-days: 1
publish-windows:
needs: [create-release]
@@ -332,7 +293,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release metadata
shell: bash
@@ -341,11 +302,11 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
@@ -368,29 +329,157 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build workspace dependencies
run: npm run build:workspace-deps --workspace=@getpaseo/app
- name: Build web app for desktop
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npx expo export --platform web
working-directory: packages/app
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args=(-- --publish never --win --x64 --arm64)
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: windows-manifest
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}.yml
retention-days: 1
finalize-rollout:
needs: [publish-macos, publish-linux, publish-windows]
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
permissions:
contents: write
runs-on: ubuntu-latest
concurrency:
group: desktop-rollout-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
package-lock.json
scripts
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download mac manifest artifacts
if: env.IS_SMOKE_TAG != 'true' && needs.publish-macos.result == 'success'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
path: release-manifests
- name: Download Linux manifest artifact
if: env.IS_SMOKE_TAG != 'true' && needs.publish-linux.result == 'success'
uses: actions/download-artifact@v4
with:
name: linux-manifest
path: release-manifests/linux-manifest
- name: Download Windows manifest artifact
if: env.IS_SMOKE_TAG != 'true' && needs.publish-windows.result == 'success'
uses: actions/download-artifact@v4
with:
name: windows-manifest
path: release-manifests/windows-manifest
- name: Assemble and upload stamped manifests
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
manifests_dir="$PWD/final"
mkdir -p "$manifests_dir"
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
manifest_name="${RELEASE_CHANNEL}-mac.yml"
node ../scripts/merge-mac-manifest.mjs \
"mac-manifest-arm64/${manifest_name}" \
"mac-manifest-x64/${manifest_name}" \
"$manifests_dir/${manifest_name}"
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
if [[ "${{ needs.publish-linux.result }}" == "success" ]]; then
cp "linux-manifest/${RELEASE_CHANNEL}-linux.yml" "$manifests_dir/"
fi
if [[ "${{ needs.publish-windows.result }}" == "success" ]]; then
cp "windows-manifest/${RELEASE_CHANNEL}.yml" "$manifests_dir/"
fi
shopt -s nullglob
files=( "$manifests_dir"/*.yml )
if (( ${#files[@]} == 0 )); then
echo "::error::No manifest artifacts were available to publish"
exit 1
fi
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
const yaml = require("js-yaml");
const fs = require("fs");
const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED);
const expectedDate = process.env.RELEASE_DATE_EXPECTED;
if (!Number.isFinite(expectedHours) || expectedHours < 0) {
throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`);
}
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
if (m.rolloutHours !== expectedHours) {
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`);
}
if (m.releaseDate !== expectedDate) {
throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`);
}
if (typeof m.version !== "string" || m.version.length === 0) {
throw new Error(`${f}: missing or invalid version`);
}
}
' "${files[@]}"
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"

152
.github/workflows/desktop-rollout.yml vendored Normal file
View File

@@ -0,0 +1,152 @@
name: Desktop Rollout
on:
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to re-stamp (e.g. v0.1.42)."
required: true
type: string
rollout_hours:
description: "Total rollout duration since the original release date, in hours. Use 0 to admit everyone immediately."
required: true
type: string
concurrency:
group: desktop-rollout-${{ inputs.tag }}
cancel-in-progress: false
env:
SOURCE_TAG: ${{ inputs.tag }}
jobs:
stamp:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
package-lock.json
scripts
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download release manifests
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir release-manifests
cd release-manifests
gh release download "$RELEASE_TAG" --repo "${{ github.repository }}" --pattern "${RELEASE_CHANNEL}*.yml"
shopt -s nullglob
files=( ./*.yml )
if (( ${#files[@]} == 0 )); then
echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG"
exit 1
fi
echo "Downloaded ${#files[@]} manifest(s):"
printf ' %s\n' "${files[@]}"
- name: Capture before state
id: before
shell: bash
run: |
set -euo pipefail
cd release-manifests
summary=$(node -e '
const yaml = require("js-yaml");
const fs = require("fs");
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
console.log(` ${f}: rolloutHours=${m.rolloutHours ?? "<unset>"} releaseDate=${m.releaseDate ?? "<unset>"}`);
}
' ./*.yml)
echo "$summary"
{
echo "summary<<EOF"
echo "$summary"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Re-stamp rolloutHours
env:
NEW_HOURS: ${{ inputs.rollout_hours }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
node ../scripts/stamp-rollout.mjs --rollout-hours "$NEW_HOURS" ./*.yml
- name: Validate rewritten manifests
env:
EXPECTED: ${{ inputs.rollout_hours }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
node -e '
const yaml = require("js-yaml");
const fs = require("fs");
const expected = Number(process.env.EXPECTED);
if (!Number.isFinite(expected) || expected < 0) {
throw new Error(`EXPECTED must be a non-negative number, got ${process.env.EXPECTED}`);
}
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
if (m.rolloutHours !== expected) {
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expected}`);
}
if (typeof m.version !== "string" || m.version.length === 0) {
throw new Error(`${f}: missing or invalid version`);
}
}
' ./*.yml
- name: Upload to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
gh release upload "$RELEASE_TAG" ./*.yml --clobber --repo "${{ github.repository }}"
- name: Write summary
env:
BEFORE: ${{ steps.before.outputs.summary }}
shell: bash
run: |
{
echo "## Rollout updated"
echo ""
echo "**Tag:** \`$RELEASE_TAG\`"
echo "**Channel:** \`$RELEASE_CHANNEL\`"
echo "**New rolloutHours:** \`${{ inputs.rollout_hours }}\`"
echo ""
echo "### Before"
echo '```'
echo "$BEFORE"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -4,12 +4,12 @@ on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
- "package.json"
- "package-lock.json"
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
- "package.json"
- "package-lock.json"
permissions:
contents: write
@@ -25,8 +25,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:

View File

@@ -4,27 +4,27 @@ on:
push:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
pull_request:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
permissions:
contents: read

View File

@@ -54,7 +54,7 @@ jobs:
fi
create_if_missing="false"
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
if [[ "$EVENT_NAME" = "push" && "$REF" == refs/tags/v* ]]; then
create_if_missing="true"
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true"

View File

@@ -4,17 +4,17 @@ on:
push:
branches: [main]
paths:
- 'packages/server/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/server-ci.yml'
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
pull_request:
branches: [main]
paths:
- 'packages/server/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/server-ci.yml'
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
jobs:
test:
@@ -27,8 +27,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main

15
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"useTabs": false,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": false,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "all",
"semi": true,
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": ["*.lock"]
}

89
.oxlintrc.json Normal file
View File

@@ -0,0 +1,89 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"categories": {
"correctness": "error",
"suspicious": "error",
"perf": "error"
},
"rules": {
"react/react-in-jsx-scope": "off",
"no-await-in-loop": "off",
"no-unused-expressions": "error",
"no-useless-catch": "error",
"preserve-caught-error": "error",
"require-await": "off",
"no-async-promise-executor": "error",
"no-useless-escape": "error",
"no-empty-pattern": "error",
"no-self-assign": "error",
"no-shadow": "error",
"unicorn/consistent-function-scoping": "off",
"unicorn/no-array-sort": "off",
"unicorn/no-useless-spread": "error",
"unicorn/no-useless-fallback-in-spread": "error",
"unicorn/no-new-array": "error",
"unicorn/no-empty-file": "error",
"promise/always-return": "error",
"promise/no-multiple-resolved": "error",
"react/no-array-index-key": "error",
"react/jsx-no-useless-fragment": "error",
"react/jsx-no-constructed-context-values": "error",
"react/no-unescaped-entities": "error",
"react/button-has-type": "error",
"react/jsx-max-depth": ["error", { "max": 6 }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"react-perf/jsx-no-new-array-as-prop": "error",
"react-perf/jsx-no-new-function-as-prop": "error",
"react-perf/jsx-no-new-object-as-prop": "error",
"react-perf/jsx-no-jsx-as-prop": "error",
"oxc/no-map-spread": "error",
"oxc/no-async-endpoint-handlers": "error",
"oxc/only-used-in-recursion": "error",
"typescript/no-explicit-any": "error",
"typescript/prefer-as-const": "error",
"typescript/no-this-alias": "error",
"typescript/consistent-type-definitions": ["error", "interface"],
"import/no-unassigned-import": [
"error",
{
"allow": [
"**/*.css",
"**/expo-router/entry",
"**/event-target-polyfill",
"**/dotenv/config",
"**/react-native",
"**/@/styles/unistyles",
"**/src/styles/unistyles",
"**/@/test/window-local-storage"
]
}
],
"no-nested-ternary": "error",
"no-unneeded-ternary": "error",
"complexity": ["error", { "max": 20 }],
"max-depth": ["error", { "max": 4 }],
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": ["**/e2e/fixtures.ts"],
"rules": {
"no-empty-pattern": "off"
}
}
]
}

View File

@@ -1,20 +1,235 @@
# Changelog
## 0.1.68 - 2026-05-05
### Fixed
- The desktop app no longer fails on first launch after a fresh install.
## 0.1.67 - 2026-05-03
### Fixed
- Archiving a worktree or workspace feels instant instead of waiting on the daemon, with automatic rollback if it fails.
- The built-in daemon toggle in desktop settings now actually takes effect.
- Desktop settings no longer reset on app launch after a legacy migration.
- Desktop daemon startup failures now surface on the splash screen and respond to retry, instead of leaving the app silently stuck.
- Internal LLM calls (branch names, commit messages, PR text) no longer leave behind ephemeral agent sessions in your provider history.
## 0.1.66 - 2026-05-03
### Fixed
- Streaming markdown preserves trailing newlines so paragraph spacing stays correct while the agent is still typing.
- Agent initialization failures surface within 30 seconds instead of 5 minutes.
- Terminals reply to ANSI cursor-position queries, so tools that ask for cursor location no longer hang.
## 0.1.65 - 2026-05-03
### Added
- **In-app browser** — open a real web browser in any workspace to test your app. ([#670](https://github.com/getpaseo/paseo/pull/670) by [@jasonkneen](https://github.com/jasonkneen))
- Inline review comments in the git diff pane. Tap a line number to start a comment. ([#530](https://github.com/getpaseo/paseo/pull/530))
- Sub-agent activity is now shown for Codex, OpenCode, and Claude. ([#672](https://github.com/getpaseo/paseo/pull/672), [#658](https://github.com/getpaseo/paseo/pull/658) by [@thisisryanswift](https://github.com/thisisryanswift))
- Pull and push your branch in one step from the git actions menu in the changes pane.
- Resume existing agent sessions with `paseo import --provider <name> <id>`. ([#632](https://github.com/getpaseo/paseo/pull/632))
- Password authentication and SSL support for daemon connections. ([#635](https://github.com/getpaseo/paseo/pull/635))
- Connect to a daemon via relay using a pairing offer URL from the CLI. ([#639](https://github.com/getpaseo/paseo/pull/639))
- **Windows:** Native ARM64 builds are now available.
- Bundled Paseo skills now refresh automatically on desktop app launch.
### Improved
- Codex streaming feels more responsive — message boundaries are preserved and output arrives sooner.
- Terminal sessions run in a dedicated worker process for better stability.
- New worktree branch names are derived from your prompt and attachments instead of a generic placeholder.
- Review comment UI is cleaner and easier to scan.
- The daemon's `/api/status` endpoint is now protected by password auth when one is configured.
### Fixed
- **Apple Silicon Mac:** The desktop update pipeline now publishes manifests atomically, closing a race that could install the Intel build on Apple Silicon Macs and cause 100%+ renderer CPU usage. Affected users will self-heal — electron-updater's Rosetta detection migrates back to arm64 on the next update poll. ([#555](https://github.com/getpaseo/paseo/issues/555))
- **Linux:** `.deb` and `.rpm` packages now show as `Paseo` in the dock and process list instead of `Paseo.bin`. `--no-sandbox` is now scoped to AppImage only, matching VS Code's sandbox handling. ([#602](https://github.com/getpaseo/paseo/issues/602))
- **Windows:** Git diff commands no longer break on paths with special characters. ([#629](https://github.com/getpaseo/paseo/pull/629))
- Cursor CLI and other ACP custom providers launch reliably. ([#628](https://github.com/getpaseo/paseo/pull/628))
- Daemon stays up when WebSocket clients disconnect mid-stream, and crashes now write a fatal log entry instead of disappearing silently. ([#613](https://github.com/getpaseo/paseo/pull/613) by [@yuruiz](https://github.com/yuruiz))
- Long agent timelines reconnect cleanly over the relay instead of looping through disconnects while catching up. ([#657](https://github.com/getpaseo/paseo/pull/657) by [@fireblue](https://github.com/fireblue))
- Agent timelines refresh with smaller catch-up requests when you reopen an agent.
- Terminal snapshots flush reliably before clients reconnect.
- Workspace reconnects avoid unnecessary refresh work when the focused workspace is already current.
- Voice dictation keeps recording when the agent tab loses focus.
- OpenCode mode picker now lists agents available in every mode. ([#606](https://github.com/getpaseo/paseo/pull/606) by [@thisisryanswift](https://github.com/thisisryanswift))
- Codex plan approval panels no longer duplicate.
- Imported agents display the correct title immediately.
- OpenCode surfaces invalid mode/model errors instead of hanging.
- Archived worktrees stay hidden without flashing back into the list. ([#640](https://github.com/getpaseo/paseo/pull/640))
- Web dropdown menus no longer resize unexpectedly.
- The visible changes pane keeps in sync with the working tree diff.
- Tool detail rows on the timeline are selectable again.
- `paseo.json` parse errors in setup, teardown, and terminal actions now surface a clear error instead of failing silently.
- Diff gutter line numbers were shifted one row out of alignment in some cases on web.
- Streamed agent output reconciles cleanly when the timeline hydrates mid-turn. ([#663](https://github.com/getpaseo/paseo/pull/663))
- Images in assistant messages show a loading spinner while they load and an "Image unavailable" fallback if they fail, instead of a blank space.
- Isolated bottom sheet modals close and re-open without getting stuck.
## 0.1.64 - 2026-04-28
### Added
- OpenCode now has a Full Access mode that auto-approves tool calls. ([#595](https://github.com/getpaseo/paseo/pull/595) by [@tmih06](https://github.com/tmih06))
- OpenCode supports executable slash commands. ([#597](https://github.com/getpaseo/paseo/pull/597) by [@tmih06](https://github.com/tmih06))
### Improved
- `@`-mention stays responsive on very large projects. ([#600](https://github.com/getpaseo/paseo/pull/600) by [@yuruiz](https://github.com/yuruiz))
### Fixed
- Workspaces still load when `paseo.json` has a parse error.
## 0.1.63 - 2026-04-28
### Added
- Project settings page with a built-in `paseo.json` editor.
- Cold start restores your last open workspace.
- Tool call badges have a button to open the referenced file directly.
- Open the current branch on GitHub from a workspace's open menu. ([#583](https://github.com/getpaseo/paseo/pull/583) by [@Myriad-Dreamin](https://github.com/Myriad-Dreamin))
- Enable or disable providers from Settings without editing config files.
- Paseo prompts you to configure a worktree setup script when one is missing.
- Choose whether the daemon shuts down when you close the desktop app.
### Improved
- Provider settings and model selection have been redesigned.
- Voice mode transcription endpoint is configurable for OpenAI-compatible providers. ([#570](https://github.com/getpaseo/paseo/pull/570) by [@yuruiz](https://github.com/yuruiz))
- Adding a project no longer waits for GitHub PR status to load.
- Startup splash screen is cleaner — just the logo with a subtle shimmer.
- `paseo.json` setup and teardown accept a single command string, not just an array.
- Archiving a worktree is instant instead of waiting for the backend to confirm.
- Agent timelines and git diff lists no longer jump around while loading or streaming.
### Fixed
- `paseo loop run` and `paseo run` now respect the `--provider` and `--model` flags. ([#594](https://github.com/getpaseo/paseo/pull/594) by [@VincenzoRocchi](https://github.com/VincenzoRocchi))
- Pi provider shows up when only DeepSeek or other non-OpenAI/Anthropic/OpenRouter API keys are set.
- Custom models from `additionalModels` and `profileModels` are honored when picking a default for new agents.
- File preview line numbers stay on one line past line 99.
- Cmd+Q on macOS quits the desktop app instead of leaving it running in the background.
- Terminal sessions recover cleanly after rendering hiccups, including the initial resize for nvim.
- Terminal protocol query responses no longer leak into the browser.
- Assistant link color matches the theme again.
- File links with line numbers (like `foo.ts:42`) open correctly from assistant messages.
- Claude's Grep results show up in the search detail body.
- Reopening a worktree lands under the right project.
- Agents from disabled or unavailable providers stay visible in history.
- New CLI agents now require a provider instead of failing silently.
- Git diff headers no longer truncate.
- Provider diagnostic modal scrolls on short screens.
- Provider diagnostics show the real error and underlying child-process output instead of a generic message.
- Archived workspaces no longer interfere with working-directory resolution.
- Triple-click on a message no longer extends the selection into adjacent bubbles.
- The packaged desktop app preserves your zsh prompt.
## 0.1.62 - 2026-04-23
### Added
- Sidebar warning when your app and daemon versions drift apart, with a shortcut to settings.
### Improved
- Workspaces appear in the sidebar immediately on startup instead of waiting for git registration.
### Fixed
- Pull request status resolves correctly for PRs opened from forks.
- Installing the paseo CLI from the macOS desktop app now works in packaged builds.
- Agents launched from the desktop app no longer inherit Electron-only environment variables.
## 0.1.61 - 2026-04-23
### Added
- `additionalModels` option in provider config lets you add or relabel models without replacing the full list — entries merge with runtime-discovered models (ACP) or your static `models` list. See the [Providers docs](https://paseo.sh/docs/providers).
- New [Providers docs page](https://paseo.sh/docs/providers) covering first-class providers and every custom provider config pattern in one place.
### Improved
- Pi loads your installed extensions on startup so their models show up in the model picker.
- Resizing the explorer sidebar no longer rerenders the rest of the workspace.
- Images in assistant messages (both file paths and inline data URLs) persist as local attachments and open in the file pane.
## 0.1.60 - 2026-04-22
### Added
- Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
- Launch scripts and services for a worktree directly from the workspace header.
- New Setup tab in every workspace showing setup, teardown, and script progress live.
- GitHub checks and PR reviews in the explorer sidebar, with a hover card for the full breakdown.
- New worktree creation flow lets you pick a base branch or check out an existing GitHub pull request.
- Attach GitHub issues and pull requests to an agent as part of its prompt context.
- Pull request pane in the workspace sidebar.
- Redesigned Settings screen with modular section navigation.
- Per-host provider configuration — set providers, models, and credentials independently on each remote host.
- Direct Pi integration replaces the ACP bridge, with faster streaming and fewer hiccups.
- Beta release channel — opt in from Settings to receive beta desktop builds before they are promoted to stable.
- New-workspace picker ranks branches by recency with fast search.
### Improved
- Workspace and tab switching are dramatically faster on desktop and mobile — you can keep many workspaces open in parallel without lag.
- Agent streams render more smoothly during heavy tool output.
- App startup routes through a stable connection and lands on the right screen without flicker.
- Provider refresh is reliable and no longer stalls on transient failures.
- Git and GitHub state stay in sync with local changes like commits, branch switches, and pushes.
- Composer attachments redesigned with a cleaner pill layout and an image lightbox.
- In-app notifications route to whichever surface you're actually looking at.
- Keyboard shortcuts keep working while Settings is open.
- Escape reliably interrupts the active agent.
- Checking out a pull request from a fork lands on an owner-prefixed branch so multiple forks don't collide.
- `paseo ls` defaults to active agents; pass `-a` to include archived.
- GitHub branch and PR picker loads faster — queries are deferred until the picker opens.
### Fixed
- Composer textarea shrinks back down after sending on web.
- New workspace drafts clear after submit instead of sticking around.
- Replacing a running agent cleans up the previous one without leaving it behind.
- Agent notifications no longer get swallowed by a backgrounded focused client.
- Removed workspace folders disappear from the workspace list again.
- Codex keeps fast mode after you approve a plan. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))
- Workspace tab focus is preserved across page refreshes.
- Settings screen no longer pushes its header down with extra spacing.
- Branch switcher title no longer overflows on narrow rows.
- iOS image picker no longer leaves the screen unresponsive after cancelling.
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
- Tool call blocks expand correctly on mobile while an agent is still streaming.
- Timeline no longer stutters when catch-up and projected ranges overlap.
- Codex no longer flashes idle when a replacement turn is in progress.
- Branch state recovers correctly when a rebase is in progress.
- Workspace hover card no longer clips near screen edges.
## 0.1.59 - 2026-04-16
### Added
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
- Extra High reasoning effort for Opus 4.7, between High and Max.
## 0.1.58 - 2026-04-16
### Added
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
- `disallowedTools` option in provider config to block specific tools from an agent.
### Improved
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
@@ -24,6 +239,7 @@
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
### Fixed
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
@@ -37,25 +253,29 @@
## 0.1.56 - 2026-04-14
### Fixed
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
- A single problematic project can no longer prevent the rest of your workspaces from loading.
## 0.1.55 - 2026-04-14
### Added
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md).
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
- Choose provider and model when creating scheduled agents.
- Max reasoning effort option for Opus 4.6 models.
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
### Improved
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
- iPad and tablet layouts work correctly across all screen sizes.
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
### Fixed
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
- Workspace navigation updates correctly when switching between workspaces in the same project.
@@ -65,13 +285,16 @@
## 0.1.54 - 2026-04-12
### Added
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
### Improved
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
### Fixed
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
@@ -79,6 +302,7 @@
## 0.1.53 - 2026-04-12
### Added
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
@@ -87,6 +311,7 @@
- Keyboard shortcut to cycle themes.
### Improved
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
- Streaming markdown on mobile renders significantly faster.
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
@@ -94,6 +319,7 @@
- Relay and pairing URLs are stripped from daemon logs.
### Fixed
- Closed agent tabs no longer reappear after reconnecting.
- Desktop notification badge counts match across all workspaces.
- Host switcher status syncs correctly when switching between hosts.
@@ -101,11 +327,13 @@
## 0.1.52 - 2026-04-10
### Added
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
### Fixed
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
@@ -118,11 +346,13 @@
## 0.1.51 - 2026-04-09
### Added
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
### Fixed
- Model selector no longer crashes on iPad.
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
- OpenCode agents show the correct terminal state and refresh models reliably.
@@ -134,6 +364,7 @@
## 0.1.50 - 2026-04-07
### Added
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
@@ -142,6 +373,7 @@
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
### Improved
- Workspaces load instantly on connect instead of waiting for a full sync.
- File explorer and diff pane remember which folders are expanded when you switch tabs.
- Closing a workspace tab is now instant.
@@ -149,6 +381,7 @@
- Reload agent moved away from the close button to prevent accidental taps.
### Fixed
- Voice mode no longer drifts into false speech detection during long sessions.
- Garbled overlapping text on plan cards.
- Changes pane could show stale diffs when working with git worktrees.
@@ -161,6 +394,7 @@
## 0.1.49 - 2026-04-07
### Fixed
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
- Model picker only shows models from the agent's own provider, not every provider on the server.
- Model lists stay consistent regardless of which screen you open first.
@@ -168,17 +402,20 @@
## 0.1.48 - 2026-04-05
### Added
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
### Improved
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
- Removed noisy agent lifecycle toasts that fired on every state change.
### Fixed
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
- Input field on running agent screens now correctly receives keyboard focus.
- Mobile model selector alignment and sizing.
@@ -186,6 +423,7 @@
## 0.1.47 - 2026-04-05
### Fixed
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
@@ -194,6 +432,7 @@
## 0.1.46 - 2026-04-04
### Fixed
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
@@ -202,6 +441,7 @@
## 0.1.45 - 2026-04-04
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
- Copilot agent provider re-enabled after ACP compatibility fixes.
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
@@ -214,11 +454,13 @@
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
### Improved
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
- Settings sections reordered for better grouping (integrations and daemon together).
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
### Fixed
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
- Silero VAD crash caused by external buffer mode in CircularBuffer.
- Bulk close now correctly archives stored agents instead of leaving orphans.
@@ -235,6 +477,7 @@
## 0.1.44 - 2026-04-03
### Fixed
- Desktop app now stops the daemon cleanly before auto-update restarts.
- Disabled claude-acp and copilot providers from the agent registry.
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
@@ -244,16 +487,19 @@
## 0.1.43 - 2026-04-02
### Added
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
- Searchable model favorites — quickly find and pin preferred models.
- Slash command support for OpenCode agents.
### Improved
- Refined model selector UX with better mobile sheet behavior.
- Workspace status now uses amber alert styling for "needs input" state.
- Themed scrollbar on message input for consistent styling.
### Fixed
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
- Shell arguments with spaces are now properly quoted on Windows.
- Claude models with 1M context support are now correctly reported.
@@ -261,11 +507,13 @@
## 0.1.42 - 2026-04-01
### Fixed
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
## 0.1.41 - 2026-04-01
### Fixed
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
@@ -277,18 +525,22 @@
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
### Improved
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
## 0.1.40 - 2026-04-01
### Added
- Workspace tabs can now be closed in batches.
### Improved
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
### Fixed
- OpenCode reasoning content no longer appears duplicated as assistant text.
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
- Archive tab now correctly reconciles agent visibility after archiving.
@@ -299,61 +551,73 @@
## 0.1.39 - 2026-03-30
### Added
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
### Fixed
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
## 0.1.38 - 2026-03-30
### Fixed
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
## 0.1.37 - 2026-03-29
### Added
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
@@ -362,6 +626,7 @@
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
@@ -378,6 +643,7 @@
## 0.1.33 - 2026-03-23
### Fixed
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
@@ -386,6 +652,7 @@
## 0.1.32 - 2026-03-23
### Added
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
- Added line numbers and word-wrap toggle to file previews.
@@ -395,6 +662,7 @@
- Added status bar tooltips for project and agent status.
### Improved
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
@@ -409,6 +677,7 @@
- Better error messages from the daemon — RPC errors now include the actual underlying details.
### Fixed
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
@@ -417,16 +686,19 @@
## 0.1.30 - 2026-03-19
### Added
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
- Added a combined model selector and agent mode visuals across key UI surfaces.
- Added Open Graph metadata improvements for richer website sharing previews.
### Improved
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
- Improved project picker path display and general workspace UI polish.
### Fixed
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
@@ -434,30 +706,36 @@
## 0.1.28 - 2026-03-15
### Added
- Added OpenCode build and plan modes.
- Added website landing pages for Claude Code, Codex, and OpenCode.
### Improved
- Improved the git action menu for more reliable repository actions.
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
- Updated the website hero copy and added a sponsor callout section.
### Fixed
- Fixed assistant file links so they open the correct workspace files from chat.
## 0.1.27 - 2026-03-13
### Added
- Added voice runtime with new audio engine architecture for voice interactions.
- Added Grep tool support in Claude tool-call mapping.
- Added ability to open workspace files directly from agent chat messages.
- Added desktop notifications via a custom native bridge.
### Improved
- Improved image picker, markdown rendering, and UI interactions.
- Improved shell environment detection using shell-env.
### Fixed
- Fixed platform-specific markdown link rendering.
- Fixed Linux AppImage CLI resource paths.
- Fixed Codex replacement stream being killed by stale turn notifications.
@@ -465,15 +743,18 @@
## 0.1.26 - 2026-03-12
### Added
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
- Added Windows support with improved cross-platform shell execution.
### Improved
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
- Added a Discord link to the website navigation.
### Fixed
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
- Fixed the home route to hide browser chrome when appropriate.
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
@@ -483,61 +764,79 @@
## 0.1.25 - 2026-03-11
### Fixed
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
## 0.1.24 - 2026-03-10
### Improved
- Improved command center keyboard navigation and new tab shortcut.
- Simplified desktop release pipeline for faster and more reliable builds.
## 0.1.21 - 2026-03-10
### Improved
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.
### Fixed
- Fixed a desktop release CI failure caused by a Unix-only server build script on Windows runners.
- Fixed server CI to build the relay dependency before running tests, restoring relay E2EE test coverage on clean runners.
- Fixed a Claude redesign test that depended on the local Claude CLI being installed.
## 0.1.20 - 2026-03-10
### Added
- Added workspace sidebar git actions with quick diff stats and archive controls.
- Added refreshed website downloads and homepage presentation for desktop installs.
### Improved
- Desktop release packaging now rebuilds and validates the bundled managed runtime during CI, improving installer reliability for macOS users.
- Improved desktop and web stream rendering, settings polish, and React 19.1.4 compatibility.
### Fixed
- Fixed Claude interrupt/restart regressions and strengthened managed-daemon smoke coverage for desktop releases.
## 0.1.19 - 2026-03-09
### Added
- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release.
## 0.1.18 - 2026-03-06
### Added
- Added a desktop `Mod+W` shortcut to close the current tab.
### Improved
- New and newly selected terminals now take focus automatically so you can type immediately.
- Kept newly created workspaces and projects in a more stable order in the sidebar.
- Improved project naming for GitHub remotes and expanded project icon discovery to Phoenix `priv/static` assets.
- Updated the website desktop download link to use the universal macOS DMG.
### Fixed
- Restored automatic agent metadata generation for Claude runs.
## 0.1.17 - 2026-03-06
### Added
- New workspace-first navigation model with workspace tabs, file tabs, and sortable tab groups.
- Keyboard shortcuts for workspace and tab navigation, with shortcut badges in the sidebar.
- Workspace-level archive actions with improved worktree archiving flow and context menu support.
- In-chat task notifications rendered as synthetic tool-call events for clearer status tracking.
### Improved
- Desktop builds now ship as a universal macOS binary (Apple Silicon + Intel).
- More reliable workspace routing and tab identity handling across refreshes and deep links.
- Better sidebar drag-and-drop behavior with explicit drag handles and nested list interactions.
@@ -545,6 +844,7 @@
- Stronger provider error surfacing and updated Claude model/runtime handling.
### Fixed
- Fixed orphan workspace runs caused by non-canonical tab routes.
- Fixed mobile terminal tab remount/routing restore issues.
- Fixed agent metadata title/branch update reliability.
@@ -552,7 +852,9 @@
- Fixed reversed edge-wheel scroll behavior in chat/tool stream views.
## 0.1.16 - 2026-02-22
### Added
- Update the Paseo desktop app and local daemon directly from Settings.
- Microphone and notification permission controls in Settings.
- Thinking/reasoning mode — agents can use extended thinking when the provider supports it.
@@ -560,6 +862,7 @@
- `paseo wait` now shows a snapshot of recent agent activity while you wait.
### Improved
- Smoother streaming with less UI flicker and scroll jumping during long agent runs.
- Faster agent sidebar list rendering.
- Archiving an agent now stops it first instead of archiving a half-running session.
@@ -567,16 +870,20 @@
- More reliable relay connections.
### Fixed
- Fixed Claude background tasks desyncing the chat.
- Fixed duplicate user messages appearing in the timeline.
- Fixed a startup crash caused by an OpenCode SDK update.
- Fixed spurious "needs attention" notifications from background agent activity.
## 0.1.15 - 2026-02-19
### Added
- Added a public changelog page on the website so users can browse release notes.
### Improved
- Redesigned the website get-started experience into a clearer two-step flow.
- Simplified website GitHub navigation and changelog headings.
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
@@ -584,11 +891,14 @@
- Hid empty filter groups in the left sidebar.
### Fixed
- Fixed archived-agent navigation by redirecting archived agent routes to draft.
- Fixed duplicate `/rewind` user-message behavior.
## 0.1.14 - 2026-02-19
### Added
- Added Claude `/rewind` command support.
- Added slash command access in the draft agent composer.
- Added `@` workspace file autocomplete in chat prompts.
@@ -597,6 +907,7 @@
- Added shared desktop/web overlay scroll handles, including file preview panes.
### Improved
- Improved worktree flow after shipping, including better merged PR detection.
- Improved draft workflow by enabling the explorer sidebar immediately after CWD selection.
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
@@ -606,6 +917,7 @@
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
### Fixed
- Fixed worktree archive/setup lifecycle issues, including terminal cleanup and archive timing.
- Fixed worktree path collisions by hashing CWD for collision-safe worktree roots.
- Fixed terminal sizing when switching back to an agent session.
@@ -619,25 +931,32 @@
- Fixed daemon version badge visibility in settings when daemon version data is unavailable.
## 0.1.9 - 2026-02-17
### Improved
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
- Reused provider availability checks for structured generation fallback selection.
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
### Fixed
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
## 0.1.8 - 2026-02-17
### Added
- Added a cross-platform confirm dialog flow for daemon restarts.
### Improved
- Simplified local speech bootstrap and daemon startup locking behavior.
- Updated website hero copy to emphasize local execution.
### Fixed
- Fixed stuck "send while running" recovery across app and server session handling.
- Fixed Claude session identity preservation when reloading existing agents.
- Fixed combobox option behavior and related interactions.
@@ -645,73 +964,93 @@
- Fixed web tool-detail wheel event routing at scroll edges.
## 0.1.7 - 2026-02-16
### Added
- Improved agent workspace flows with better directory suggestions.
- Added iOS TestFlight and Android app access request forms on the website.
### Improved
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
- Improved website app download and update guidance.
### Fixed
- Prevented an initial desktop combobox `0,0` position flash.
- Fixed CLI version output issues.
- Hardened server runtime loading for local speech dependencies.
## 0.1.6 - 2026-02-16
### Notes
- No major visible product changes in this patch release.
## 0.1.5 - 2026-02-16
### Added
- Added terminal reattach support and better worktree terminal handling.
- Added global keyboard shortcut help in the app.
- Added sidebar host filtering and improved agent workflow controls.
### Improved
- Improved worktree setup visibility by streaming setup progress.
- Improved terminal streaming reliability and lifecycle handling.
- Preserved explorer tab state so context survives navigation better.
## 0.1.4 - 2026-02-14
### Added
- Added voice capability status reporting in the client.
- Added background local speech model downloads with runtime gating.
- Added adaptive dictation finish timing based on server-provided budgets.
- Added relay reconnect behavior with grace periods and branch suggestions.
### Improved
- Improved connection selection and agent hydration reliability.
- Improved timeline loading with cursor-based fetch behavior.
- Improved first-run experience by bootstrapping a default localhost connection.
- Improved inline code rendering by auto-linkifying URLs.
### Fixed
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
- Fixed stale relay client timer behavior.
- Fixed unnecessary git diff header auto-scroll on collapse.
## 0.1.3 - 2026-02-12
### Added
- Added CLI onboarding command.
- Added CLI `--output-schema` support for structured agent output.
- Added CLI agent metadata update support for names and labels.
- Added provider availability detection with normalization of legacy default model IDs.
### Improved
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
- Added better voice interrupt handling with a speech-start grace period.
- Improved CLI defaults to list all non-archived agents by default.
- Improved website UX with clearer install CTA and privacy policy access.
### Fixed
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
## 0.1.2 - 2026-02-11
### Notes
- No major visible product changes in this patch release.
## 0.1.1 - 2026-02-11
### Added
- Initial `0.1.x` release line.

View File

@@ -17,17 +17,17 @@ This is an npm workspace monorepo:
## Documentation
| Doc | What's in it |
|---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| Doc | What's in it |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/design.md](docs/design.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start
@@ -36,11 +36,12 @@ npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
npm run lint # Always run after changes
npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Critical rules
@@ -53,8 +54,14 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- 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 after every change.**
- **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.
- 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:
- `npm run lint -- packages/app/src/components/message.tsx`
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
@@ -68,23 +75,23 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
### The four gates
| Gate | Type | When to use |
|---|---|---|
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
| Gate | Type | When to use |
| -------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------- |
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
### Decision matrix
| I need to... | Use |
|---|---|
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
| I need to... | Use |
| -------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
### Rules
@@ -96,6 +103,14 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
use-audio-recorder.native.ts ← uses expo-audio
```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
```
components/
browser-pane.electron.tsx ← Electron <webview> implementation
browser-pane.web.tsx ← plain web fallback
browser-pane.tsx ← native fallback
```
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
@@ -104,5 +119,4 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
## Debugging
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log

View File

@@ -27,10 +27,10 @@ If you want to propose a direction change, start a conversation.
Please read these first:
- [README.md](README.md)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
- [docs/TESTING.md](docs/TESTING.md)
- [docs/architecture.md](docs/architecture.md)
- [docs/development.md](docs/development.md)
- [docs/coding-standards.md](docs/coding-standards.md)
- [docs/testing.md](docs/testing.md)
- [CLAUDE.md](CLAUDE.md)
## What is most helpful
@@ -105,7 +105,7 @@ npm run dev:website
npm run cli -- ls -a -g
```
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
## Multi-platform testing
@@ -132,7 +132,7 @@ If you touch protocol or shared client/server behavior, read the compatibility n
Paseo has explicit standards. Follow them.
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
The full guide lives in [docs/coding-standards.md](docs/coding-standards.md).
## PR checklist

View File

@@ -69,6 +69,7 @@ paseo
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
@@ -116,6 +117,7 @@ Then use them in any agent conversation:
## Development
Quick monorepo package map:
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
- `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows
@@ -142,6 +144,16 @@ npm run build:daemon
npm run typecheck
```
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## License
AGPL-3.0

View File

@@ -1,5 +0,0 @@
{
"android": {
"package": "com.moboudra.paseo"
}
}

View File

@@ -1,32 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!*.lock"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
},
"linter": {
"enabled": false
}
}

View File

@@ -1,158 +0,0 @@
# Attachment-Based Review Context Plan
## Goal
Use structured attachments as the source of truth for review context during agent creation.
This covers two related behaviors:
1. Provider-facing context for the agent prompt
2. Worktree checkout behavior when creating a worktree from a review item
The design should stay compatible with future forge support such as GitLab.
## Core Direction
- Do not parse the prompt to discover issue/PR intent.
- Do not introduce deeply nested transport objects for this flow.
- Make the attachment itself the discriminated union.
- Keep forge-specific checkout logic below the attachment layer.
- Let each agent provider translate attachments into its own prompt/input format.
## Initial Attachment Types
Two initial structured attachment types:
- `github_pr`
- `github_issue`
With separate MIME types:
- `application/github-pr`
- `application/github-issue`
Proposed wire shape:
```ts
type AgentAttachment =
| {
type: "github_pr";
mimeType: "application/github-pr";
number: number;
title: string;
url: string;
body?: string | null;
baseRefName?: string | null;
headRefName?: string | null;
}
| {
type: "github_issue";
mimeType: "application/github-issue";
number: number;
title: string;
url: string;
body?: string | null;
};
```
## Backward Compatibility
- Add new optional `attachments` fields; keep existing `images` fields.
- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request.
- Malformed attachment entries should also be ignored safely.
- Old clients continue working because `attachments` is optional.
- New clients can send `attachments` without breaking older flows that still rely on `images`.
## Request-Level Changes
Add optional `attachments` to:
- `create_agent_request`
- `send_agent_message_request`
Existing `initialPrompt` remains plain user text.
The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth.
## App Responsibilities
When the user selects a GitHub item:
- If it is a PR, create a `github_pr` attachment
- If it is an issue, create a `github_issue` attachment
- Include the attachment in the create-agent request
The UI can still render friendly labels and previews from the same data.
The app should stop treating PR/issue context as prompt-only metadata.
## Worktree Creation Behavior
During agent creation, if worktree creation is requested:
- Inspect normalized attachments
- If a `github_pr` attachment is present, use it to drive checkout for the worktree
The important rule:
- attachment type identifies the review object
- server-side git logic decides how to check it out
This keeps the attachment contract simple while allowing fork-safe implementation details.
## Checkout Resolution
The attachment itself should not encode the full checkout strategy.
Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic.
Examples of possible implementation strategies:
- `gh pr checkout`
- `git fetch origin refs/pull/<number>/head:<local-branch>`
- another GitHub-aware resolver if needed later
This is intentionally a server implementation detail, not part of the attachment schema.
## Provider Responsibilities
Each provider adapter should receive normalized attachments and decide how to represent them for the model.
Examples:
- Claude: render attachment into text blocks
- Codex: inject as blocks or text
- OpenCode: send as file/resource-like input where appropriate
- ACP: convert to resource/text forms as supported
The session layer should not hardcode one translation strategy for all providers.
## Suggested Implementation Order
1. Add tolerant attachment schema and normalization in shared/server message handling.
2. Thread `attachments` through app -> daemon client -> session layer.
3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection.
4. Teach worktree creation to inspect attachments and special-case `github_pr`.
5. Update provider adapters to translate attachments into provider-specific prompt/input forms.
6. Optionally migrate image transport into the same attachment mechanism later.
## Files Likely Involved
- `packages/server/src/shared/messages.ts`
- `packages/server/src/client/daemon-client.ts`
- `packages/server/src/server/session.ts`
- `packages/server/src/server/worktree-session.ts`
- `packages/server/src/server/agent/agent-sdk-types.ts`
- `packages/server/src/server/agent/providers/claude-agent.ts`
- `packages/server/src/server/agent/providers/codex-app-server-agent.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/acp-agent.ts`
- `packages/app/src/screens/new-workspace-screen.tsx`
- `packages/app/src/screens/agent/draft-agent-screen.tsx`
- `packages/app/src/contexts/session-context.tsx`
## Notes
- The current image path can remain in place initially.
- The plan intentionally keeps checkout concerns separate from provider prompt translation.
- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields.

View File

@@ -1,428 +0,0 @@
# Data Model
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
---
## Directory layout
```
$PASEO_HOME/
├── config.json # Daemon configuration
├── agents/
│ └── {project-dir}/
│ └── {agentId}.json # One file per agent
├── schedules/
│ └── {scheduleId}.json # One file per schedule
├── chat/
│ └── rooms.json # All rooms + messages
├── loops/
│ └── loops.json # All loop records
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
└── push-tokens.json # Expo push notification tokens
```
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
| Field | Type | Description |
|---|---|---|
| `title` | `string?` | Configured title |
| `modeId` | `string?` | Configured mode |
| `model` | `string?` | Configured model |
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
| `extra` | `Record<string, any>?` | Provider-specific config |
| `systemPrompt` | `string?` | Custom system prompt |
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
### Nested: RuntimeInfo
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Active provider |
| `sessionId` | `string?` | Active session ID |
| `model` | `string?` | Active model |
| `thinkingOptionId` | `string?` | Active thinking option |
| `modeId` | `string?` | Active mode |
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
### Nested: PersistenceHandle
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Provider that owns the session |
| `sessionId` | `string` | Session ID for resumption |
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
| `metadata` | `Record<string, any>?` | Extra metadata |
### Nested: AgentFeature (discriminated union on `type`)
**Toggle:**
| Field | Type |
|---|---|
| `type` | `"toggle"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `boolean` |
**Select:**
| Field | Type |
|---|---|
| `type` | `"select"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string?` |
| `options` | `AgentSelectOption[]` |
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
Single file, validated with `PersistedConfigSchema`.
```
{
version: 1,
daemon: {
listen: "127.0.0.1:6767",
hostnames: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
},
app: {
baseUrl: string
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }
},
agents: {
providers: {
[provider: string]: {
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
env: Record<string, string>
}
}
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
console: { level, format },
file: { level, path, rotate: { maxSize, maxFiles } }
}
}
```
All fields are optional with sensible defaults.
---
## 3. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char hex ID |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | The prompt to send |
| `cadence` | `ScheduleCadence` | Timing (see below) |
| `target` | `ScheduleTarget` | What to run (see below) |
| `status` | `"active" \| "paused" \| "completed"` | Current state |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
| `pausedAt` | `string?` (ISO 8601) | When paused |
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
| `maxRuns` | `number?` | Max executions before completing |
| `runs` | `ScheduleRun[]` | Execution history |
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
### Nested: ScheduleTarget (discriminated union on `type`)
- `{ type: "agent", agentId: string }` — send to existing agent
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
### Nested: ScheduleRun
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Run ID |
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
| `startedAt` | `string` (ISO 8601) | |
| `endedAt` | `string?` (ISO 8601) | |
| `status` | `"running" \| "succeeded" \| "failed"` | |
| `agentId` | `string?` (UUID) | Agent used for this run |
| `output` | `string?` | Agent output text |
| `error` | `string?` | Error message if failed |
---
## 4. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
Single file containing all rooms and messages.
```json
{
"rooms": [ ... ],
"messages": [ ... ]
}
```
### ChatRoom
| Field | Type | Description |
|---|---|---|
| `id` | `string` (UUID) | |
| `name` | `string` | Unique room name (case-insensitive) |
| `purpose` | `string?` | Room description |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
### ChatMessage
| Field | Type | Description |
|---|---|---|
| `id` | `string` (UUID) | |
| `roomId` | `string` | FK to ChatRoom.id |
| `authorAgentId` | `string` | Agent ID of the author |
| `body` | `string` | Message text (supports `@mentions`) |
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
| `createdAt` | `string` (ISO 8601) | |
---
## 5. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
Single file containing an array of all loop records.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char UUID prefix |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | Worker prompt |
| `cwd` | `string` | Working directory |
| `provider` | `string` | Default provider |
| `model` | `string?` | Default model |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifyPrompt` | `string?` | LLM verification prompt |
| `verifyChecks` | `string[]` | Shell commands to run as checks |
| `archive` | `boolean` | Whether to archive worker agents after use |
| `sleepMs` | `number` | Delay between iterations (ms) |
| `maxIterations` | `number?` | Cap on iterations |
| `maxTimeMs` | `number?` | Total time budget (ms) |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `startedAt` | `string` (ISO 8601) | |
| `completedAt` | `string?` (ISO 8601) | |
| `stopRequestedAt` | `string?` (ISO 8601) | |
| `iterations` | `LoopIteration[]` | |
| `logs` | `LoopLogEntry[]` | |
| `nextLogSeq` | `number` | Monotonic log sequence counter |
| `activeIteration` | `number?` | Currently executing iteration index |
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
### Nested: LoopIteration
| Field | Type | Description |
|---|---|---|
| `index` | `number` | 1-based iteration index |
| `workerAgentId` | `string?` | Agent ID of the worker |
| `workerStartedAt` | `string` (ISO 8601) | |
| `workerCompletedAt` | `string?` (ISO 8601) | |
| `verifierAgentId` | `string?` | Agent ID of the verifier |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
| `failureReason` | `string?` | |
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
### Nested: LoopLogEntry
| Field | Type |
|---|---|
| `seq` | `number` (monotonic) |
| `timestamp` | `string` (ISO 8601) |
| `iteration` | `number?` |
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
| `level` | `"info" \| "error"` |
| `text` | `string` |
### Nested: LoopVerifyCheckResult
| Field | Type |
|---|---|
| `command` | `string` |
| `exitCode` | `number` |
| `passed` | `boolean` |
| `stdout` | `string` |
| `stderr` | `string` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
### Nested: LoopVerifyPromptResult
| Field | Type |
|---|---|
| `passed` | `boolean` |
| `reason` | `string` |
| `verifierAgentId` | `string?` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
---
## 6. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
Array of project records.
| Field | Type | Description |
|---|---|---|
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 7. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
|---|---|---|
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 8. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
```json
{
"tokens": ["ExponentPushToken[...]", ...]
}
```
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
---
## Client-side stores (App)
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
### Draft Store
**AsyncStorage key:** `paseo-drafts` (version 2)
```typescript
{
drafts: Record<draftKey, {
input: { text: string, images: AttachmentMetadata[] },
lifecycle: "active" | "abandoned" | "sent",
updatedAt: number, // epoch ms
version: number // optimistic concurrency
}>,
createModalDraft: DraftRecord | null
}
```
### Attachment Store (Web)
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
Stores binary attachment blobs keyed by attachment ID.
### AttachmentMetadata
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique attachment ID |
| `mimeType` | `string` | MIME type |
| `storageType` | `string` | Storage backend identifier |
| `storageKey` | `string` | Key within the storage backend |
| `createdAt` | `number` | Epoch ms |
| `fileName` | `string?` | Original filename |
| `byteSize` | `number?` | Size in bytes |

View File

@@ -1,109 +0,0 @@
# File Icons
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Icons are inlined as SVG strings in:
```
packages/app/src/components/material-file-icons.ts
```
This file is auto-generated. Do not edit it by hand.
## How it works
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
## Adding a new icon
1. Find the icon name in the material-icon-theme manifest:
```bash
node -e "
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
console.log('languageIds:', m.languageIds['YOUR_LANG']);
"
```
2. Verify the SVG exists:
```bash
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
```
3. Add two things to `material-file-icons.ts`:
- The SVG string in `SVG_ICONS`:
```ts
"icon_name": `<svg ...>...</svg>`,
```
- The extension mapping in `EXTENSION_TO_ICON`:
```ts
"ext": "icon_name",
```
4. Run `npm run typecheck` to verify.
## Currently included icons
53 unique icons covering these extensions:
| Extension(s) | Icon |
|---|---|
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |

View File

@@ -1,166 +0,0 @@
# Unistyles Gotchas
This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## How Updates Propagate
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
[`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.
## 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.
Avoid this pattern when the style depends on the theme:
```tsx
<ScrollView contentContainerStyle={styles.container} />
const styles = StyleSheet.create((theme) => ({
container: {
flexGrow: 1,
backgroundColor: theme.colors.surface0,
},
}));
```
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, and library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
## Fix Patterns
Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free.
```tsx
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.contentContainer}>
{children}
</ScrollView>
</View>
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flexGrow: 1,
padding: theme.spacing[4],
},
}));
```
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
```tsx
import { ScrollView } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
const ThemedScrollView = withUnistyles(ScrollView);
<ThemedScrollView
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
/>
```
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
```tsx
const { theme } = useUnistyles();
<ScrollView
contentContainerStyle={[
styles.contentContainer,
{ backgroundColor: theme.colors.surface0 },
]}
/>
```
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
## Hidden Sheet Content
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:
```tsx
const { theme } = useUnistyles();
<Text style={[styles.title, { color: theme.colors.foreground }]}>
{title}
</Text>
```
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
## Adaptive Themes And Persisted Settings
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:
1. Unistyles config starts with `adaptiveThemes: true`.
2. The device may report system light.
3. Settings load a persisted non-auto preference, such as dark.
4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`.
That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
## 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`:
```js
[
"react-native-unistyles/plugin",
{
root: "src",
debug: true,
},
],
```
Then rebuild the bundle and look for lines such as:
```text
src/components/welcome-screen.tsx: styles.container: [Theme]
```
This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.
For paint-layer bugs, use high-contrast probes:
1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta.
2. Cold-restart the app, not just Fast Refresh.
3. Screenshot the simulator and sample pixels to see which color fills the area.
4. Remove the probes before committing.
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
## References
- [Unistyles v3 documentation](https://www.unistyl.es/)
- [Theming: initial theme, adaptive themes, and runtime theme changes](https://www.unistyl.es/v3/guides/theming)
- [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue)
- [withUnistyles reference](https://www.unistyl.es/v3/references/with-unistyles)
- [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views)
- [Babel plugin debug option](https://www.unistyl.es/v3/other/babel-plugin#debug)
- [Why my view doesn't update?](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update)
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
- [Local research note: welcome theme split](</Users/moboudra/.paseo/notes/welcome-theme-split-research.md>)

View File

@@ -58,6 +58,7 @@ await rm(staticDir, { recursive: true, force: true });
```
Run with:
```bash
npx tsx packages/server/src/server/your-script.ts
```
@@ -107,6 +108,7 @@ const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "i
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
Always pass `appVersion`:
```typescript
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,

View File

@@ -4,9 +4,9 @@
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
| Variant | App name | Package ID |
|---|---|---|
| `production` | Paseo | `sh.paseo` |
| Variant | App name | Package ID |
| ------------- | ----------- | ---------------- |
| `production` | Paseo | `sh.paseo` |
| `development` | Paseo Debug | `sh.paseo.debug` |
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.
@@ -51,7 +51,7 @@ 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)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
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.
### Useful commands

View File

@@ -53,17 +53,17 @@ The heart of Paseo. A Node.js process that:
**Key modules:**
| Module | Responsibility |
|---|---|
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
| Module | Responsibility |
| ------------------------- | ----------------------------------------------------------------------------- |
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
### `packages/app` — Mobile + web client (Expo)
@@ -132,10 +132,26 @@ Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capab
**Binary multiplexing:**
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- Channel 0: control messages
- Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload
### Compatibility rules
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
Example: adding a new enum value
```ts
// 1. Add CLIENT_CAPS.newThing = "new_thing"
// 2. Let new clients advertise it in WS hello
// 3. Keep the shared producer schema strict
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
## Agent lifecycle
```
@@ -153,13 +169,14 @@ initializing → idle → running → idle (or error → closed)
Each provider implements a common `AgentClient` interface:
| Provider | Wraps | Session format |
|---|---|---|
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode CLI | Provider-managed |
| Provider | Wraps | Session format |
| -------- | ------------------- | -------------------------------------------------- |
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode CLI | Provider-managed |
All providers:
- Handle their own authentication (Paseo does not manage API keys)
- Support session resume via persistence handles
- Map tool calls to a normalized `ToolCallDetail` type

View File

@@ -40,7 +40,10 @@ No complex inline types in public function signatures.
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
// Good
interface EnqueueJobInput { userId: string; priority: "low" | "normal" | "high" }
interface EnqueueJobInput {
userId: string;
priority: "low" | "normal" | "high";
}
function enqueueJob(input: EnqueueJobInput) {}
```
@@ -53,7 +56,11 @@ If a function needs more than one argument, use a single object parameter.
function createToolCall(provider: string, toolName: string, payload: unknown) {}
// Good: object param
interface CreateToolCallInput { provider: string; toolName: string; payload: unknown }
interface CreateToolCallInput {
provider: string;
toolName: string;
payload: unknown;
}
function createToolCall(input: CreateToolCallInput) {}
```
@@ -78,7 +85,11 @@ Use discriminated unions instead of bags of booleans and optionals.
```typescript
// Bad
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
interface FetchState {
isLoading: boolean;
error?: Error;
data?: Data;
}
// Good
type FetchState =
@@ -136,7 +147,10 @@ Avoid packing branching, lookup, and transformation into single dense expression
// Bad: nested ternaries + inline lookups
const billing = shouldUseLegacy(account)
? getLegacy(account)
: buildBilling(account, rates.find((r) => r.region === account.region));
: buildBilling(
account,
rates.find((r) => r.region === account.region),
);
// Good: named steps, then assemble
const rate = rates.find((r) => r.region === account.region);

View File

@@ -55,6 +55,7 @@ Use `extends` to create a new provider entry that inherits from a built-in provi
```
Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
@@ -95,12 +96,12 @@ Required fields for custom providers:
### Available models
| Model | Tier |
|---|---|
| `glm-5.1` | Advanced (flagship) |
| `glm-5-turbo` | Advanced |
| `glm-4.7` | Standard |
| `glm-4.5-air` | Lightweight |
| Model | Tier |
| ------------- | ------------------- |
| `glm-5.1` | Advanced (flagship) |
| `glm-5-turbo` | Advanced |
| `glm-4.7` | Standard |
| `glm-4.5-air` | Lightweight |
### Notes
@@ -148,10 +149,10 @@ Required fields for custom providers:
### API endpoints
| Mode | Base URL |
|---|---|
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
| Mode | Base URL |
| ------------------------------- | ----------------------------------------------------------- |
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
@@ -159,13 +160,13 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
**Recommended for coding plan:**
| Model | Notes |
|---|---|
| `qwen3.5-plus` | Vision capable, recommended |
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-M2.5` | MiniMax |
| Model | Notes |
| ------------------ | --------------------------- |
| `qwen3.5-plus` | Vision capable, recommended |
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-M2.5` | MiniMax |
**Additional models (pay-as-you-go):**
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
@@ -221,16 +222,12 @@ You can also combine profiles with model overrides to pin specific models per pr
"claude-fast": {
"extends": "claude",
"label": "Claude (Fast)",
"models": [
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }
]
"models": [{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }]
},
"claude-smart": {
"extends": "claude",
"label": "Claude (Smart)",
"models": [
{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }
]
"models": [{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }]
}
}
}
@@ -338,6 +335,7 @@ Set `extends: "acp"` and provide a `command`:
```
Required fields for ACP providers:
- `extends: "acp"`
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
@@ -424,44 +422,85 @@ Models and modes are discovered dynamically at runtime from the agent process. I
Profile models (defined in config.json) completely replace runtime-discovered models when present.
If you want to keep runtime-discovered models and add or relabel a few entries, use `additionalModels` instead.
Example: add an experimental model while keeping every model the provider discovers at runtime:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [
{ "id": "experimental-model", "label": "Experimental", "isDefault": true }
]
}
}
}
}
```
Example: relabel a discovered model without replacing the full list:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [{ "id": "provider/model-id", "label": "My Preferred Label" }]
}
}
}
}
```
When an `additionalModels` entry has the same `id` as a discovered model, it updates that model in place.
---
## Provider override reference
Every entry under `agents.providers` accepts these fields:
| Field | Type | Required | Description |
|---|---|---|---|
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
| Field | Type | Required | Description |
| ------------------ | ------------------------ | ----------------- | ------------------------------------------------------------------ |
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
### Model definition
Each entry in the `models` array:
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Model identifier sent to the provider |
| `label` | `string` | Yes | Display name in the UI |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default model selection |
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
| Field | Type | Required | Description |
| ----------------- | ------------------ | -------- | ------------------------------------- |
| `id` | `string` | Yes | Model identifier sent to the provider |
| `label` | `string` | Yes | Display name in the UI |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default model selection |
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
### Thinking option
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Thinking option identifier |
| `label` | `string` | Yes | Display name |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
| Field | Type | Required | Description |
| ------------- | --------- | -------- | ----------------------------------- |
| `id` | `string` | Yes | Thinking option identifier |
| `label` | `string` | Yes | Display name |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Gotcha: `extends: "claude"` with third-party endpoints

428
docs/data-model.md Normal file
View File

@@ -0,0 +1,428 @@
# Data Model
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
---
## Directory layout
```
$PASEO_HOME/
├── config.json # Daemon configuration
├── agents/
│ └── {project-dir}/
│ └── {agentId}.json # One file per agent
├── schedules/
│ └── {scheduleId}.json # One file per schedule
├── chat/
│ └── rooms.json # All rooms + messages
├── loops/
│ └── loops.json # All loop records
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
└── push-tokens.json # Expo push notification tokens
```
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
| Field | Type | Description |
| ------------------ | -------------------------- | ---------------------------- |
| `title` | `string?` | Configured title |
| `modeId` | `string?` | Configured mode |
| `model` | `string?` | Configured model |
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
| `extra` | `Record<string, any>?` | Provider-specific config |
| `systemPrompt` | `string?` | Custom system prompt |
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
### Nested: RuntimeInfo
| Field | Type | Description |
| ------------------ | -------------------------- | ------------------------------ |
| `provider` | `string` | Active provider |
| `sessionId` | `string?` | Active session ID |
| `model` | `string?` | Active model |
| `thinkingOptionId` | `string?` | Active thinking option |
| `modeId` | `string?` | Active mode |
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
### Nested: PersistenceHandle
| Field | Type | Description |
| -------------- | ---------------------- | --------------------------------------------------------------------- |
| `provider` | `string` | Provider that owns the session |
| `sessionId` | `string` | Session ID for resumption |
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
| `metadata` | `Record<string, any>?` | Extra metadata |
### Nested: AgentFeature (discriminated union on `type`)
**Toggle:**
| Field | Type |
| ------------- | ---------- |
| `type` | `"toggle"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `boolean` |
**Select:**
| Field | Type |
| ------------- | --------------------- |
| `type` | `"select"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string?` |
| `options` | `AgentSelectOption[]` |
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
Single file, validated with `PersistedConfigSchema`.
```
{
version: 1,
daemon: {
listen: "127.0.0.1:6767",
hostnames: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
},
app: {
baseUrl: string
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }
},
agents: {
providers: {
[provider: string]: {
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
env: Record<string, string>
}
}
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
console: { level, format },
file: { level, path, rotate: { maxSize, maxFiles } }
}
}
```
All fields are optional with sensible defaults.
---
## 3. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
| ----------- | ------------------------------------- | -------------------------------- |
| `id` | `string` | 8-char hex ID |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | The prompt to send |
| `cadence` | `ScheduleCadence` | Timing (see below) |
| `target` | `ScheduleTarget` | What to run (see below) |
| `status` | `"active" \| "paused" \| "completed"` | Current state |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
| `pausedAt` | `string?` (ISO 8601) | When paused |
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
| `maxRuns` | `number?` | Max executions before completing |
| `runs` | `ScheduleRun[]` | Execution history |
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
### Nested: ScheduleTarget (discriminated union on `type`)
- `{ type: "agent", agentId: string }` — send to existing agent
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
### Nested: ScheduleRun
| Field | Type | Description |
| -------------- | -------------------------------------- | ----------------------- |
| `id` | `string` | Run ID |
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
| `startedAt` | `string` (ISO 8601) | |
| `endedAt` | `string?` (ISO 8601) | |
| `status` | `"running" \| "succeeded" \| "failed"` | |
| `agentId` | `string?` (UUID) | Agent used for this run |
| `output` | `string?` | Agent output text |
| `error` | `string?` | Error message if failed |
---
## 4. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
Single file containing all rooms and messages.
```json
{
"rooms": [ ... ],
"messages": [ ... ]
}
```
### ChatRoom
| Field | Type | Description |
| ----------- | ------------------- | ----------------------------------- |
| `id` | `string` (UUID) | |
| `name` | `string` | Unique room name (case-insensitive) |
| `purpose` | `string?` | Room description |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
### ChatMessage
| Field | Type | Description |
| ------------------ | ------------------- | ----------------------------------- |
| `id` | `string` (UUID) | |
| `roomId` | `string` | FK to ChatRoom.id |
| `authorAgentId` | `string` | Agent ID of the author |
| `body` | `string` | Message text (supports `@mentions`) |
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
| `createdAt` | `string` (ISO 8601) | |
---
## 5. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
Single file containing an array of all loop records.
| Field | Type | Description |
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
| `id` | `string` | 8-char UUID prefix |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | Worker prompt |
| `cwd` | `string` | Working directory |
| `provider` | `string` | Default provider |
| `model` | `string?` | Default model |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifyPrompt` | `string?` | LLM verification prompt |
| `verifyChecks` | `string[]` | Shell commands to run as checks |
| `archive` | `boolean` | Whether to archive worker agents after use |
| `sleepMs` | `number` | Delay between iterations (ms) |
| `maxIterations` | `number?` | Cap on iterations |
| `maxTimeMs` | `number?` | Total time budget (ms) |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `startedAt` | `string` (ISO 8601) | |
| `completedAt` | `string?` (ISO 8601) | |
| `stopRequestedAt` | `string?` (ISO 8601) | |
| `iterations` | `LoopIteration[]` | |
| `logs` | `LoopLogEntry[]` | |
| `nextLogSeq` | `number` | Monotonic log sequence counter |
| `activeIteration` | `number?` | Currently executing iteration index |
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
### Nested: LoopIteration
| Field | Type | Description |
| ------------------- | --------------------------------------------------- | ------------------------ |
| `index` | `number` | 1-based iteration index |
| `workerAgentId` | `string?` | Agent ID of the worker |
| `workerStartedAt` | `string` (ISO 8601) | |
| `workerCompletedAt` | `string?` (ISO 8601) | |
| `verifierAgentId` | `string?` | Agent ID of the verifier |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
| `failureReason` | `string?` | |
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
### Nested: LoopLogEntry
| Field | Type |
| ----------- | ---------------------------------------------------- |
| `seq` | `number` (monotonic) |
| `timestamp` | `string` (ISO 8601) |
| `iteration` | `number?` |
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
| `level` | `"info" \| "error"` |
| `text` | `string` |
### Nested: LoopVerifyCheckResult
| Field | Type |
| ------------- | ------------------- |
| `command` | `string` |
| `exitCode` | `number` |
| `passed` | `boolean` |
| `stdout` | `string` |
| `stderr` | `string` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
### Nested: LoopVerifyPromptResult
| Field | Type |
| ----------------- | ------------------- |
| `passed` | `boolean` |
| `reason` | `string` |
| `verifierAgentId` | `string?` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
---
## 6. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
Array of project records.
| Field | Type | Description |
| ------------- | -------------------- | ------------------------------ |
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 7. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ----------------------- |
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 8. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
```json
{
"tokens": ["ExponentPushToken[...]", ...]
}
```
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
---
## Client-side stores (App)
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
### Draft Store
**AsyncStorage key:** `paseo-drafts` (version 2)
```typescript
{
drafts: Record<draftKey, {
input: { text: string, images: AttachmentMetadata[] },
lifecycle: "active" | "abandoned" | "sent",
updatedAt: number, // epoch ms
version: number // optimistic concurrency
}>,
createModalDraft: DraftRecord | null
}
```
### Attachment Store (Web)
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
Stores binary attachment blobs keyed by attachment ID.
### AttachmentMetadata
| Field | Type | Description |
| ------------- | --------- | ------------------------------ |
| `id` | `string` | Unique attachment ID |
| `mimeType` | `string` | MIME type |
| `storageType` | `string` | Storage backend identifier |
| `storageKey` | `string` | Key within the storage backend |
| `createdAt` | `number` | Epoch ms |
| `fileName` | `string?` | Original filename |
| `byteSize` | `number?` | Size in bytes |

247
docs/design-system.md Normal file
View File

@@ -0,0 +1,247 @@
# Design system
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
---
## 1. Character
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
---
## 2. Component reuse
A semantic element used in three or more places is a primitive. One of a kind is a screen.
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
Before adding a new component, read `components/ui/`. The primitive usually exists.
---
## 3. Hierarchy
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
Weight has three tiers, applied by role:
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
---
## 4. Buttons
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
## 5. Borders
Borders group, separate, or rarely emphasize.
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
---
## 6. Pickers
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<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.
`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`.
---
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx:1056-1062`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 812px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
The whitespace is the design.
---
## 8. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
## 9. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
Terminology:
- Workspace, never "checkout".
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
- Project, not "repo" or "repository".
- Provider, not "model provider".
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
---
## 10. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like daemon version mismatch and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
---
## 11. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
---
## 12. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
## 13. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
---
## 14. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |

View File

@@ -17,7 +17,7 @@ Before designing anything new, understand what exists:
- Where does similar functionality live?
- What patterns does the codebase already use?
- What layers exist? (See [ARCHITECTURE.md](./ARCHITECTURE.md))
- What layers exist? (See [architecture.md](./architecture.md))
- What types and data shapes are already defined?
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
@@ -38,7 +38,7 @@ If you can't define verification, you don't understand the feature well enough y
- What types are needed?
- Use discriminated unions — make impossible states impossible
- One canonical type per concept (see [CODING_STANDARDS.md](./CODING_STANDARDS.md))
- One canonical type per concept (see [coding-standards.md](./coding-standards.md))
### Layers

View File

@@ -22,10 +22,16 @@ PASEO_HOME=~/.paseo-blue npm run dev
```
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
and `config.json`; actual checkout/worktree directories are not copied.
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
### Default ports
In the main checkout:
- Daemon: `localhost:6767`
- Expo app: `localhost:8081`
@@ -54,6 +60,45 @@ npm run db:query -- --db /path/to/db "SELECT ..."
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
```json
{
"worktree": {
"setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
"teardown": "npm run db:drop || true"
}
}
```
Every `scripts` entry with `"type": "service"` receives these environment variables:
| 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>_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. |
`<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:
```json
{
"scripts": {
"web": {
"type": "service",
"command": "PORT=$PASEO_PORT npm run dev:web"
}
}
}
```
## Build sync gotchas
### Relay → Daemon
@@ -103,11 +148,13 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
Find an agent by ID:
```bash
find $PASEO_HOME/agents -name "{agent-id}.json"
```
Find by content:
```bash
rg -l "some title text" $PASEO_HOME/agents/
```
@@ -117,11 +164,13 @@ rg -l "some title text" $PASEO_HOME/agents/
Get the session ID from the agent JSON (`persistence.sessionId`), then:
**Claude:**
```
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
```
**Codex:**
```
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
```

109
docs/file-icons.md Normal file
View File

@@ -0,0 +1,109 @@
# File Icons
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Icons are inlined as SVG strings in:
```
packages/app/src/components/material-file-icons.ts
```
This file is auto-generated. Do not edit it by hand.
## How it works
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
## Adding a new icon
1. Find the icon name in the material-icon-theme manifest:
```bash
node -e "
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
console.log('languageIds:', m.languageIds['YOUR_LANG']);
"
```
2. Verify the SVG exists:
```bash
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
```
3. Add two things to `material-file-icons.ts`:
- The SVG string in `SVG_ICONS`:
```ts
"icon_name": `<svg ...>...</svg>`,
```
- The extension mapping in `EXTENSION_TO_ICON`:
```ts
"ext": "icon_name",
```
4. Run `npm run typecheck` to verify.
## Currently included icons
53 unique icons covering these extensions:
| Extension(s) | Icon |
| ------------------------------------------ | ----------- |
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |

45
docs/glossary.md Normal file
View File

@@ -0,0 +1,45 @@
# Paseo Glossary
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 a project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:17`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:88`). 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:2128`). 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:9`).
- **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:597`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` (`packages/server/src/shared/messages.ts:1886`), `DaemonClient` (`packages/server/src/client/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:36`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2063`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` (`packages/server/src/shared/messages.ts:2027`); `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:2042`); 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:36`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:193`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:182`).
- **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:249`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:736`).
- **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:576`); (b) **git merge conflict** (no current UI string).
## Open question — per-host project entry (TBD)
A project aggregates workspaces across daemons. The projects screen and project-settings screen need a name for "one row in the project list per (project, daemon)". `ProjectPlacementPayload` is **per-workspace**, so it isn't this thing — but the noun "placement" could naturally extend (`ProjectDaemonPlacement`, or just "placements grouped by daemon"). The in-progress `ProjectCheckout` type (`packages/app/src/utils/projects.ts:4`) is also per-workspace today, even though the settings UI selector treats it per-daemon (matched on `serverId` alone) — so the code is currently inconsistent with itself.
Candidates: (1) extend "placement" to the (project, daemon) bundle, (2) drop the wrapper type and use `{ host, project, workspaces[] }` plus descriptive UI copy ("project · host X · 2 online"). Recommendation: option (2) — no new noun, use existing terms compositionally. Do not introduce "Checkout" / `ProjectCheckout` as the canonical name.
## Rename before landing (in-progress `Checkout*` references)
- `packages/app/src/utils/projects.ts:4,20,22,23,46,85,104,120,124,126,146,148,155``ProjectCheckout`, `checkouts`, `checkoutCount`, `onlineCheckoutCount`, `buildCheckoutTarget`, `compareCheckouts`.
- `packages/app/src/screens/projects-screen.tsx:73,74,77,90``checkoutLabel`, `${checkoutCount} checkout(s)`, `onlineSuffix`.
- `packages/app/src/screens/project-settings-screen.tsx:17,41,46,55,76,81,110-152,323,443-600``ProjectCheckout` import, `CheckoutSelection`, `CheckoutSelector`, `CheckoutOption`, `usableCheckouts`, `onlineUsableCheckouts`, `selectedCheckout`, `selectedCheckoutKey`, "no online checkout", "no checkout with a valid server", `testID="checkout-selector"`, `testID="checkout-option-*"`, a11y `"Edit X checkout"`.
- `packages/app/src/screens/project-settings-screen.test.tsx:185,195,196,366,369,378,389,412,415,431,432,456,464,478,481,490,501,504,521``checkouts`, `checkoutCount`, `onlineCheckoutCount`, "no checkouts are online", "checkout selector", "online checkout".
- `packages/app/src/utils/projects.test.ts:107,108,111,259``checkoutCount`, `onlineCheckoutCount`, `checkouts`.
(Out of scope for this rename: `ProjectCheckoutLite*Payload` and the git-`checkout` family in `packages/server/src/utils/checkout-*.ts`. Those refer to _git_ checkout state, not the rejected (project, daemon) sense.)
## Inconsistencies (documented, not papered over)
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/commands/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:2137`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:9`).
- In-progress `ProjectCheckout` (`packages/app/src/utils/projects.ts:4`) is per-workspace, but `project-settings-screen.tsx` selects by `serverId` alone — same daemon with multiple workspaces will produce duplicate React keys in the selector.

View File

@@ -110,6 +110,33 @@ See `image-picker-repro.yaml` for an example.
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
### New Workspace Creation
The Android workspace-creation regression has a dedicated harness:
```bash
bash packages/app/maestro/test-workspace-create-android-crash.sh
```
For a short recording that starts after launch/connection/sidebar setup:
```bash
bash packages/app/maestro/record-workspace-create-android-focus.sh
```
The flow details are documented in `packages/app/maestro/README.md`. The important rule is that a valid new-workspace assertion must prove the redirect completed: select a real model, tap `Create`, wait for `workspace-header-title`, wait for `message-input-root`, assert `New workspace` is gone, and assert the Android redbox strings are absent. Waiting for the composer alone is too weak because it can still be the `/new` route after a validation error.
New workspace scenarios should compose the reusable subflows in `packages/app/maestro/flows/`:
- `android-dev-client.yaml`
- `connect-direct-if-welcome.yaml`
- `open-prepared-project-sidebar.yaml`
- `new-workspace-open-from-sidebar.yaml`
- `new-workspace-select-codex-gpt54.yaml`
- `new-workspace-submit-and-assert-created.yaml`
The workspace-create shell scripts render those subflows into a temp directory before running Maestro, which keeps nested `runFlow` paths and `${PASEO_MAESTRO_*}` placeholders working together.
### Inputs that Maestro types into
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
@@ -196,7 +223,7 @@ const styles = StyleSheet.create((theme) => ({
},
}));
<Animated.View style={[styles.sidebar, animatedStyle]} />
<Animated.View style={[styles.sidebar, animatedStyle]} />;
```
```tsx
@@ -217,7 +244,7 @@ const { theme } = useUnistyles();
<Animated.View
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
/>
/>;
```
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
@@ -234,4 +261,4 @@ xcrun simctl ui booted appearance dark # set dark
xcrun simctl ui booted appearance light # set light
```
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [development.md](development.md)).

View File

@@ -1,68 +0,0 @@
# Plan Approval Normalization
## Goal
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
## Compatibility Constraints
- Older clients must remain compatible with newer daemons.
- All new wire fields must be optional.
- Existing plan permissions without action metadata must still render and work.
- Existing question permissions must keep their current behavior.
## Design
### Shared abstraction
Add optional permission action definitions to the shared permission request/response types.
- Permission requests may include `actions`.
- Permission responses may include `selectedActionId`.
- `kind: "plan"` remains the normalized concept for plan approval.
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
### Claude
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
- Always expose `Reject`.
- Always expose `Implement`.
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
- Resolve the selected action entirely inside `respondToPermission()`.
### Codex
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
- Emit a plan permission with `Reject` and `Implement` actions.
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
- On `Reject`, resolve without starting a follow-up turn.
- Keep the implementation prompt and state transitions inside the Codex provider.
### Manager and state sync
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
- Refresh current mode
- Refresh pending permissions
- Refresh runtime info
- Refresh features
- Persist refreshed state
### UI
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
- If `actions` are absent, fall back to legacy buttons.
- Plan cards should use `Implement` as the default primary label.
- Do not add provider-specific rendering branches.
## Verification
1. Shared schema/type tests for optional `actions` and `selectedActionId`
2. App tests for generic plan-action rendering
3. Claude tests for third action when resuming from a more permissive mode
4. Codex tests for synthetic plan approval and automatic implementation follow-up
5. Manager tests for post-permission state refresh
6. `npm run typecheck`

View File

@@ -30,6 +30,7 @@ Each project opens as a workspace. For git projects, the default workspace is th
### Inside a workspace
A workspace is a flexible canvas:
- Launch multiple agents side by side in split panes
- Open terminals alongside agents
- Mix and match providers within the same workspace
@@ -39,6 +40,7 @@ A workspace is a flexible canvas:
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
This architecture means:
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when you close the app

View File

@@ -58,10 +58,10 @@ type MyProviderClientOptions = {
export class MyProviderACPAgentClient extends ACPAgentClient {
constructor(options: MyProviderClientOptions) {
super({
provider: "my-provider", // Must match the ID used everywhere else
provider: "my-provider", // Must match the ID used everywhere else
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
defaultModes: MY_PROVIDER_MODES,
capabilities: MY_PROVIDER_CAPABILITIES,
});
@@ -70,7 +70,7 @@ export class MyProviderACPAgentClient extends ACPAgentClient {
// Override isAvailable() if the provider needs specific auth/env vars
override async isAvailable(): Promise<boolean> {
if (!(await super.isAvailable())) {
return false; // Binary not found
return false; // Binary not found
}
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
}
@@ -212,8 +212,8 @@ export const agentConfigs = {
provider: "my-provider",
model: "default-model-id",
modes: {
full: "autonomous", // Mode with no permission prompts
ask: "default", // Mode that requires permission approval
full: "autonomous", // Mode with no permission prompts
ask: "default", // Mode that requires permission approval
},
},
} as const satisfies Record<string, AgentTestConfig>;
@@ -264,8 +264,15 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:

View File

@@ -7,7 +7,7 @@ All workspaces share one version and release together.
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. **Release candidate 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**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch)
@@ -37,31 +37,120 @@ npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Release candidate flow
## Beta flow
```bash
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
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
```
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and 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 RC tag
- 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
- `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`
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
- 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.
Use the RC path when you need to:
Use the beta path when you need to:
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
## Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
Desktop release builds now publish in two phases:
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
### Default behavior
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
### Instant-admit release (rollout_hours=0 from publish)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
```bash
# 1. Cut and publish (default 24h ramp from tag push).
npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
gh workflow run desktop-rollout.yml \
-f tag=v0.1.64 \
-f rollout_hours=0
```
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=24`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
### Adjusting an already-published release
To change the rollout duration on a release that's already shipped — e.g. flip a hotfix to instant admit, or slow a release down — use the dedicated `desktop-rollout.yml` workflow. It edits the manifests in place on the GitHub release without rebuilding anything. It only rewrites `rolloutHours`; `releaseDate` is preserved, so the rollout clock keeps ticking from the original publish time.
**Hotfix (instant admit) on an already-shipped release:**
```bash
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=0
```
`rollout_hours=0` admits 100% of stable users on their next update check (within ~30 min for active clients).
**Slow a rollout down** (e.g. extend total duration to 72h since the original release):
```bash
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=72
```
`rollout_hours` is **total duration since the original release date**, not "extend by N more hours from now." If `v0.1.42` was published 2h ago and you set `rollout_hours=72`, the ramp finishes 70h from now.
The dispatch is idempotent and shares the `desktop-rollout-<tag>` concurrency group with `desktop-release.yml`'s `finalize-rollout` job, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
### Custom ramp on a manually-dispatched build
`desktop-release.yml` accepts `rollout_hours` only on `workflow_dispatch`, which is the path used to **rebuild an existing tag** (retry a failed release, force a rebuild on a different ref). When you go that route, you can stamp a non-default ramp directly:
```bash
gh workflow run desktop-release.yml \
-f tag=v0.1.43 \
-f rollout_hours=6
```
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
### Releasing during an active rollout
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
### Limitations
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
- **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.
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build
@@ -88,13 +177,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
# Beta
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
@@ -105,24 +194,26 @@ 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
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
- 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
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
Release notes depend on the changelog heading format. The heading **must** be strictly followed:
```
## 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` is for **final stable releases only**.
- Do not add or edit changelog entries while iterating on RCs.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
- Between stable releases, keep changelog work out of the repo until the final release is ready.
- `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 ownership
@@ -137,6 +228,8 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **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.
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
@@ -166,7 +259,17 @@ Rules:
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
- **Credit the commit author, not the PR opener.** A maintainer often opens a PR that lands work authored by someone else (cherry-pick, rebase of a contributor's branch, manual extraction from a stacked PR). The squash commit preserves the original commit's author, but `gh pr view N --json author` returns the PR opener — using that field will silently mis-credit the work to the maintainer (and then the "skip @boudra" rule drops the attribution entirely). Always resolve attribution from commit authors.
Use this command to get the GitHub logins for each PR:
```bash
gh pr view N --json commits --jq '[.commits[].authors[].login] | unique | .[]'
```
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
## Changelog ordering
@@ -178,7 +281,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
@@ -196,10 +299,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
- **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, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
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.
## Completion checklist

View File

@@ -79,7 +79,9 @@ const realSender: EmailSender = { send: sendgrid.send };
function createTestEmailSender() {
const sent: Array<{ to: string; body: string }> = [];
return {
send: async (to: string, body: string) => { sent.push({ to, body }); },
send: async (to: string, body: string) => {
sent.push({ to, body });
},
sent,
};
}
@@ -115,6 +117,7 @@ The test output is the source of truth, not your reading of the code.
## Design for testability
If code isn't testable, refactor it. Signs:
- You want to reach for a mock
- You can't inject a dependency
- You need to test private internals

302
docs/unistyles.md Normal file
View File

@@ -0,0 +1,302 @@
# Unistyles Gotchas
This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Forbidden
**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):
> 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.
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
### 1. `StyleSheet.create((theme) => ...)` — default
Most theme-aware styling needs nothing else. The Babel plugin tracks theme dependencies inside the factory and updates the native ShadowTree without any React re-render.
```tsx
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.surface0,
padding: theme.spacing[4],
},
}));
<View style={styles.container} />;
```
If you are reading a theme value just to feed it back into a `style` prop, you almost certainly want this and not the hook.
### 2. Hard-coded constants for genuinely static values
If you only need a number that happens to live on the theme (e.g. a fixed spacing value used to compute a gap or animation distance), use a literal constant or import a static module. Static reads do not need a subscription. See the "Static Theme Imports" section below — importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static.
### 3. `withUnistyles(Component)` for third-party props
When a third-party component takes a non-`style` prop that must be theme-reactive (e.g. `BlurView.tint`, `Image.tintColor`, navigator option props, bottom-sheet `backgroundStyle`), wrap that single component with `withUnistyles`. Only the wrapper re-renders, not the surrounding tree.
```tsx
const ThemedBlur = withUnistyles(BlurView);
<ThemedBlur tint={theme.colors.surface0} />;
```
(Mind the `> *` child-selector leak documented further down.)
### 4. Lift the read into a tiny leaf component
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 / `AgentStreamSection` / `AgentComposerSection`
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, tabs row, 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.
## How Updates Propagate
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
[`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.
## 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.
Avoid this pattern when the style depends on the theme:
```tsx
<ScrollView contentContainerStyle={styles.container} />;
const styles = StyleSheet.create((theme) => ({
container: {
flexGrow: 1,
backgroundColor: theme.colors.surface0,
},
}));
```
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
## Fix Patterns
Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free.
```tsx
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.contentContainer}>{children}</ScrollView>
</View>;
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flexGrow: 1,
padding: theme.spacing[4],
},
}));
```
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
```tsx
import { ScrollView } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
const ThemedScrollView = withUnistyles(ScrollView);
<ThemedScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer} />;
```
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
```tsx
const { theme } = useUnistyles();
<ScrollView
contentContainerStyle={[styles.contentContainer, { backgroundColor: theme.colors.surface0 }]}
/>;
```
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
## `withUnistyles` And The `> *` Child-Selector Leak
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
```css
.unistyles_j2k2iilhfz {
flex: 1 1 0%;
background-color: var(--colors-surface0);
}
.unistyles_j2k2iilhfz > * {
flex: 1 1 0%;
background-color: var(--colors-surface0);
}
```
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
Symptoms to watch for:
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }` `View` pick up an unexpected background.
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
Quick confirmation in DevTools console:
```js
[...document.styleSheets]
.flatMap((s) => [...(s.cssRules || [])])
.map((r) => r.cssText)
.filter((t) => t.includes("unistyles") && t.includes("> *"));
```
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
## Hidden Sheet Content
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:
```tsx
const { theme } = useUnistyles();
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>;
```
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
The same rule applies to bottom-sheet component props such as `backgroundStyle` and `handleIndicatorStyle`: they are library props, not the direct React Native `style` prop Unistyles registers. Prefer a custom `backgroundComponent` that calls `useUnistyles()`, or pass a small inline object from the hook theme.
## Memoized Style Objects
When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads.
Avoid indirect keys like this:
```tsx
const { theme, rt } = useUnistyles();
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
```
On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object.
Prefer the hook theme itself, or explicit theme tokens, as the dependency:
```tsx
const { theme } = useUnistyles();
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
```
If a style factory is cheap, skipping `useMemo` entirely is also fine.
## Static Theme Imports
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
Use `useUnistyles()` inside the component instead:
```tsx
const { theme } = useUnistyles();
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
```
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
## Adaptive Themes And Persisted Settings
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:
1. Unistyles config starts with `adaptiveThemes: true`.
2. The device may report system light.
3. Settings load a persisted non-auto preference, such as dark.
4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`.
That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
## 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`:
```js
[
"react-native-unistyles/plugin",
{
root: "src",
debug: true,
},
],
```
Then rebuild the bundle and look for lines such as:
```text
src/components/welcome-screen.tsx: styles.container: [Theme]
```
This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.
For paint-layer bugs, use high-contrast probes:
1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta.
2. Cold-restart the app, not just Fast Refresh.
3. Screenshot the simulator and sample pixels to see which color fills the area.
4. Remove the probes before committing.
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
## References
- [Unistyles v3 documentation](https://www.unistyl.es/)
- [Theming: initial theme, adaptive themes, and runtime theme changes](https://www.unistyl.es/v3/guides/theming)
- [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue)
- [withUnistyles reference](https://www.unistyl.es/v3/references/with-unistyles)
- [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views)
- [Babel plugin debug option](https://www.unistyl.es/v3/other/babel-plugin#debug)
- [Why my view doesn't update?](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update)
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
- [Local research note: welcome theme split](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md)

96
knip.json Normal file
View File

@@ -0,0 +1,96 @@
{
"$schema": "./node_modules/knip/schema.json",
"workspaces": {
".": {
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
},
"packages/server": {
"entry": [
"src/server/index.ts",
"src/server/exports.ts",
"src/utils/tool-call-parsers.ts",
"src/shared/**/*.ts",
"src/client/**/*.ts",
"src/server/agent/agent-sdk-types.ts",
"src/server/agent/provider-manifest.ts",
"scripts/**/*.{ts,mts,mjs,cjs,js}",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.e2e.ts",
"src/**/*.e2e.tsx"
],
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,mts,mjs,cjs,js}"]
},
"packages/app": {
"entry": [
"index.ts",
"app.config.js",
"babel.config.js",
"app/**/*.{ts,tsx}",
"src/**/*.test.{ts,tsx}",
"src/**/*.e2e.{ts,tsx}",
"src/**/*.native.{ts,tsx}",
"e2e/**/*.{ts,tsx}",
"playwright.config.{ts,js}",
"vitest.config.{ts,js}",
"test-stubs/**/*.ts"
],
"project": ["**/*.{ts,tsx,js,jsx}"],
"paths": {
"@server/*": ["../server/src/*"]
},
"ignore": ["android/**", "ios/**", ".expo/**", "dist/**", "scripts/reset-project.js"]
},
"packages/cli": {
"entry": ["src/index.ts", "bin/paseo", "src/**/*.test.{ts,tsx}", "tests/**/*.{ts,tsx}"],
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"]
},
"packages/relay": {
"entry": ["src/index.ts", "src/e2ee.ts", "src/cloudflare-adapter.ts", "src/**/*.test.ts"],
"project": ["src/**/*.ts"]
},
"packages/website": {
"entry": ["src/router.tsx", "vite.config.ts", "src/routes/**/*.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/routeTree.gen.ts"]
},
"packages/desktop": {
"entry": ["src/main.ts", "src/preload.ts", "src/**/*.test.ts"],
"project": ["src/**/*.ts"]
},
"packages/highlight": {
"entry": ["src/index.ts", "src/**/*.test.ts"],
"project": ["src/**/*.ts"]
},
"packages/expo-two-way-audio": {
"entry": ["src/index.ts"],
"project": ["src/**/*.ts"]
}
},
"ignoreDependencies": [
"sherpa-onnx-node",
"@playwright/test",
"material-icon-theme",
"eas-cli",
"wait-on",
"concurrently",
"get-port-cli",
"patch-package",
"cross-env",
"expo-module-scripts",
"buffer",
"metro-config"
],
"ignoreBinaries": [
"expo-module",
"xed",
"eas",
"playwright",
"wrangler",
"powershell",
"tsx",
"vitest",
"open"
]
}

11
lefthook.yml Normal file
View File

@@ -0,0 +1,11 @@
pre-commit:
parallel: true
jobs:
- name: format
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
run: npm run format:check:files -- {staged_files}
- name: lint
glob: "*.{js,jsx,ts,tsx}"
run: npm run lint -- {staged_files}
- name: typecheck
run: npm run typecheck

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-Q8FCZ5cQs45ZL0XeaRzgZXmROmywT0YI/1Rqdlpbt/w=";
npmDepsHash = "sha256-Pjfl4RV+2keXdYWMPonsPkwPAbVNSgczmwSeQRwNSu4=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

5099
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,26 @@
{
"name": "paseo",
"version": "0.1.59",
"version": "0.1.68",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
"development",
"mcp",
"openai",
"realtime",
"voice",
"voice-assistant"
],
"homepage": "https://paseo.sh",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
@@ -19,14 +38,20 @@
"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",
"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",
"test": "npm run test --workspaces --if-present",
"format": "biome format --write .",
"format:check": "biome format .",
"format": "oxfmt .",
"format:files": "oxfmt",
"format:check": "oxfmt --check .",
"format:check:files": "oxfmt --check",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"knip": "knip",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -38,7 +63,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 version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --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 --",
"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",
@@ -47,63 +72,43 @@
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
"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:push": "node scripts/push-current-release-tag.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
"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: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",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
"knip": "^5.82.1",
"lefthook": "^2.1.6",
"oxfmt": "0.46.0",
"oxlint": "1.61.0",
"patch-package": "^8.0.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "^5.9.3"
"playwright": "^1.56.1",
"typescript": "^5.9.3",
"ws": "^8.20.0"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
"voice",
"voice-assistant",
"development",
"mcp"
],
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1",
"expo": "~54.0.33",
"react": "19.1.0",
"react-native": "0.81.5"
}
}

View File

@@ -5,6 +5,7 @@ on:
tags:
- "v*"
- "!v*-rc.*"
- "!v*-beta.*"
workflow_dispatch: {}
jobs:
@@ -37,3 +38,15 @@ jobs:
params:
build_id: ${{ needs.build_android.outputs.build_id }}
profile: production
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [submit_ios]
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Submit for review
run: bundle exec fastlane ios submit_review

View File

@@ -37,6 +37,9 @@ yarn-error.*
# typescript
*.tsbuildinfo
# vitest browser failure screenshots
.vitest-screenshots/
app-example
# generated native folders

3
packages/app/Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

View File

@@ -4,8 +4,12 @@ import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
clickSessionRow,
closeWorkspaceAgentTab,
connectArchiveTabDaemonClient,
createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden,
@@ -100,4 +104,26 @@ test.describe("Archive tab reconciliation", () => {
await passivePage.close();
}
});
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-control-${randomUUID().slice(0, 8)}`,
});
await resetSeededPageState(page);
await openWorkspaceWithAgents(page, [archived, surviving]);
await closeWorkspaceAgentTab(page, archived.id);
await archiveAgentFromDaemon(client, archived.id);
await openSessions(page);
await expectSessionRowArchived(page, archived.title);
await clickSessionRow(page, archived.title);
await expectArchivedAgentFocused(page, archived.id);
});
});

View File

@@ -0,0 +1,61 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
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;
}
test.describe("Codex plan approval", () => {
test("shows a single actionable plan panel and removes it after implementation starts", async ({
page,
}) => {
test.setTimeout(180_000);
const repo = await createTempGitRepo("codex-plan-approval-");
const client = await connectTerminalClient();
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 waitForPermissionPrompt(page, 120_000);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(1);
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
await allowPermission(page);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, {
timeout: 30_000,
});
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,61 @@
import { test } from "./fixtures";
import {
collapseFolder,
expandFolder,
expectExplorerEntryHidden,
expectExplorerEntryVisible,
expectFileTabOpen,
openFileExplorer,
openFileFromExplorer,
} from "./helpers/file-explorer";
import { gotoWorkspace } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: WorkspaceSetupDaemonClient;
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" },
],
});
seedClient = await connectWorkspaceSetupClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) {
throw new Error(result.error ?? "Failed to seed workspace");
}
workspaceId = String(result.workspace.id);
});
test.afterAll(async () => {
await seedClient?.close();
await tempRepo?.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 openFileExplorer(page);
await expandFolder(page, "assets");
await expectExplorerEntryVisible(page, "logo.png");
await openFileFromExplorer(page, "logo.png");
await expectFileTabOpen(page, "assets/logo.png");
await collapseFolder(page, "assets");
await expectExplorerEntryHidden(page, "logo.png");
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
});
});

View File

@@ -3,12 +3,12 @@ import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-r
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
baseURL: async ({}, use) => {
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
}
await use(`http://localhost:${metroPort}`);
await provide(`http://localhost:${metroPort}`);
},
});
@@ -67,7 +67,7 @@ test.beforeEach(async ({ page }) => {
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
({ daemon, preferences, seedNonce: nonce }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
@@ -75,13 +75,13 @@ test.beforeEach(async ({ page }) => {
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
if (disableValue === nonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));

View File

@@ -1,19 +1,20 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
type WaitForServerOptions = {
interface WaitForServerOptions {
host?: string;
timeoutMs?: number;
label: string;
childProcess?: ChildProcess | null;
getRecentOutput?: () => string;
};
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -124,16 +125,21 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
}
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
let pendingResolve: (() => void) | null = resolve;
const settle = () => {
if (!pendingResolve) return;
const fn = pendingResolve;
pendingResolve = null;
clearTimeout(timeout);
fn();
};
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
resolve();
settle();
}, 5000);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
child.once("exit", settle);
});
}
@@ -182,17 +188,77 @@ 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 relayProcess: ChildProcess | null = null;
type OfferPayload = {
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
if (trimmed === "current") {
return resolvePaseoHomePath("~/.paseo");
}
return resolvePaseoHomePath(trimmed);
}
interface OfferPayload {
v: 2;
serverId: string;
daemonPublicKeyB64: string;
relay: { endpoint: string };
};
}
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
if (args[0] === "pr" && args[1] === "list") {
console.log(JSON.stringify([
{
number: 515,
title: "Review selected start ref",
url: "https://github.com/getpaseo/paseo/pull/515",
state: "OPEN",
body: "Fixture pull request for app e2e.",
labels: [],
baseRefName: "main",
headRefName: "feature/start-from-pr"
}
]));
process.exit(0);
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
console.error("no pull requests found for branch");
process.exit(1);
}
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
}
console.error("Unsupported fake gh invocation: " + args.join(" "));
process.exit(1);
`,
);
await chmod(ghPath, 0o755);
return binDir;
}
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, "");
return input.replace(ANSI_PATTERN, "");
}
function ensureRelayBuildArtifact(repoRoot: string): void {
@@ -269,37 +335,44 @@ async function waitForPairingOfferFromCli(args: {
);
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
interface DictationConfig {
openAiUsable: boolean;
localModelsDir: string | null;
}
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
}
const port = await getAvailablePort();
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
const cleanup = async () => {
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
}
};
async function applyPaseoHomeFork(targetHome: string): Promise<void> {
const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM);
if (!forkSourceHome) {
return;
}
const forkResult = await forkPaseoHomeMetadata({
sourceHome: forkSourceHome,
targetHome,
});
process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome;
process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome;
process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles);
process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes);
console.log(
`[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` +
`(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` +
`${forkResult.copiedBytes} bytes)`,
);
if (forkResult.skippedMissing.length > 0) {
console.warn(
`[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`,
);
}
}
async function resolveDictationConfig(): Promise<DictationConfig> {
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
@@ -322,212 +395,292 @@ export default async function globalSetup() {
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
);
return { openAiUsable, localModelsDir };
}
interface RelayStreamState {
failureLine: string | null;
readyForSelectedPort: boolean;
}
function attachRelayStreamHandlers(
child: ChildProcess,
relayPort: number,
buffer: ReturnType<typeof createLineBuffer>,
state: RelayStreamState,
): void {
function handleChunk(data: Buffer, streamTag: "stdout" | "stderr") {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[${streamTag}] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
state.failureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
state.readyForSelectedPort = true;
}
if (streamTag === "stdout") {
console.log(`[relay] ${line}`);
} else {
console.error(`[relay] ${line}`);
}
}
}
child.stdout?.on("data", (data: Buffer) => handleChunk(data, "stdout"));
child.stderr?.on("data", (data: Buffer) => handleChunk(data, "stderr"));
}
async function awaitRelayReady(
child: ChildProcess,
relayPort: number,
state: RelayStreamState,
buffer: ReturnType<typeof createLineBuffer>,
): Promise<void> {
await waitForServer(relayPort, {
label: "Relay dev server",
timeoutMs: 30000,
childProcess: child,
getRecentOutput: buffer.dump,
});
const readyDeadline = Date.now() + 5000;
function isRelayReadyCheckPending(): boolean {
if (state.readyForSelectedPort) return false;
if (state.failureLine !== null) return false;
if (child.exitCode !== null) return false;
if (child.signalCode !== null) return false;
if (Date.now() >= readyDeadline) return false;
return true;
}
while (isRelayReadyCheckPending()) await sleep(100);
if (state.failureLine) {
throw new Error(`Relay startup failed: ${state.failureLine}`);
}
if (!state.readyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
buffer.dump,
)}`,
);
}
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${child.exitCode}, signal ${child.signalCode}).${formatRecentOutput(
buffer.dump,
)}`,
);
}
}
async function startRelay(): 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 buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
},
);
attachRelayStreamHandlers(relayProcess, relayPort, buffer, state);
try {
await awaitRelayReady(relayProcess, relayPort, state, buffer);
return relayPort;
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
relayProcess = null;
}
}
const message =
lastRelayStartupError instanceof Error
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
);
}
function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "..");
const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
return child;
}
interface DaemonSpawnArgs {
port: number;
relayPort: number;
metroPort: number;
paseoHome: string;
fakeGhBinDir: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
}
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const child = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
PASEO_NODE_ENV: "development",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let stdoutBuffer = "";
child.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
args.buffer.add(`[stdout] ${trimmed}`);
console.log(`[daemon] ${trimmed}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
args.buffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
}
});
return child;
}
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome && shouldRemovePaseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
}
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
await loadEnvTestFile(repoRoot);
const port = await getAvailablePort();
const metroPort = await getAvailablePort();
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 metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
await applyPaseoHomeFork(paseoHome);
const cleanup = () => performCleanup(shouldRemovePaseoHome);
const dictation = await resolveDictationConfig();
try {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let relayStarted = false;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
relayPort = await getAvailablePort();
relayLineBuffer = createLineBuffer();
let relayStartupFailureLine: string | null = null;
let relayReadyForSelectedPort = false;
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
},
);
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stdout] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stderr] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.error(`[relay] ${line}`);
}
});
try {
await waitForServer(relayPort, {
label: "Relay dev server",
timeoutMs: 30000,
childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump,
});
const readyDeadline = Date.now() + 5000;
while (
!relayReadyForSelectedPort &&
relayStartupFailureLine === null &&
relayProcess?.exitCode === null &&
relayProcess?.signalCode === null &&
Date.now() < readyDeadline
) {
await sleep(100);
}
if (relayStartupFailureLine) {
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
}
if (!relayReadyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
relayLineBuffer.dump,
)}`,
);
}
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
relayLineBuffer.dump,
)}`,
);
}
relayStarted = true;
break;
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
relayProcess = null;
}
}
if (!relayStarted) {
const message =
lastRelayStartupError instanceof Error
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
);
}
// Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, "..");
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none", // Don't auto-open browser
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
const relayPort = await startRelay();
metroProcess = startMetro(metroPort, metroLineBuffer);
daemonProcess = startDaemon({
port,
relayPort,
metroPort,
paseoHome,
fakeGhBinDir,
dictation,
buffer: daemonLineBuffer,
});
metroProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
metroProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let stdoutBuffer = "";
daemonProcess.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
console.log(`[daemon] ${trimmed}`);
}
});
daemonProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
daemonLineBuffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
}
});
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port, {
label: "Paseo daemon",
@@ -536,7 +689,7 @@ export default async function globalSetup() {
}),
waitForServer(metroPort, {
label: "Metro web server",
timeoutMs: 120000, // Metro can take longer to start
timeoutMs: 120000,
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
}),

View File

@@ -7,22 +7,22 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
export type ScrollMetrics = {
export interface ScrollMetrics {
offsetY: number;
contentHeight: number;
viewportHeight: number;
distanceFromBottom: number;
};
}
export type SeededAgent = {
export interface SeededAgent {
id: string;
title: string;
expectedTailText: string;
url: string;
workspaceUrl: string;
};
}
export type DaemonClientInstance = {
export interface DaemonClientInstance {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
@@ -36,7 +36,7 @@ export type DaemonClientInstance = {
}): 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;
@@ -84,17 +84,15 @@ export function createReplyTurn(label: string): {
};
}
type DaemonClientConfig = {
interface DaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
async function loadDaemonClientConstructor(): Promise<
new (
config: DaemonClientConfig,
) => DaemonClientInstance
new (config: DaemonClientConfig) => DaemonClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(

View File

@@ -27,19 +27,19 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
}
const needsReset = await page.evaluate(
({ expectedEndpoint, expectedServerId }) => {
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const entry = parsed[0] as { serverId?: string; connections?: unknown };
if (entry?.serverId !== serverId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: any) =>
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" &&
typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
@@ -47,7 +47,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
)
return true;
return !connections.some(
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === endpoint,
);
} catch {
return true;
@@ -68,10 +69,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon, preferences }) => {
({ daemon: seededDaemon, preferences: seededPreferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
@@ -80,6 +81,68 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
await page.reload();
}
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
let registry: unknown;
try {
registry = JSON.parse(registryRaw);
} catch {
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0] as { serverId?: string; connections?: unknown };
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
return { serverId: daemon.serverId, connections: daemon.connections };
}
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
if (
!Array.isArray(connections) ||
!connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (
Array.isArray(connections) &&
connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
);
}
}
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
try {
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (prefs?.serverId !== serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
cause: error,
});
}
}
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
@@ -98,66 +161,18 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
}
let registry: any;
try {
registry = JSON.parse(snapshot.registryRaw);
} catch {
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
if (serverId !== expectedServerId) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0];
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
if (daemon.serverId !== expectedServerId) {
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
);
}
const connections: unknown = daemon?.connections;
if (
!Array.isArray(connections) ||
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (
Array.isArray(connections) &&
connections.some(
(c: any) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
);
}
assertDaemonConnections(connections, expectedEndpoint);
if (!snapshot.prefsRaw) {
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
}
try {
const prefs = JSON.parse(snapshot.prefsRaw) as any;
if (prefs?.serverId !== daemon.serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
}
assertPreferencesMatch(snapshot.prefsRaw, serverId);
}
export const gotoAppShell = async (page: Page) => {
@@ -308,7 +323,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (trimmedDirectory.startsWith("/private/var/")) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
}
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
const basename = trimmedDirectory.split("/").findLast(Boolean) ?? trimmedDirectory;
await expect
.poll(
@@ -343,8 +358,8 @@ export const ensureHostSelected = async (page: Page) => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any;
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;

View File

@@ -11,13 +11,13 @@ import {
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export type ArchiveTabAgent = {
export interface ArchiveTabAgent {
id: string;
title: string;
cwd: string;
};
}
type ArchiveTabDaemonClient = {
interface ArchiveTabDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
@@ -36,7 +36,7 @@ type ArchiveTabDaemonClient = {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
};
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -73,17 +73,15 @@ function buildSeededStoragePayload() {
};
}
type ArchiveTabDaemonClientConfig = {
interface ArchiveTabDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
async function loadDaemonClientConstructor(): Promise<
new (
config: ArchiveTabDaemonClientConfig,
) => ArchiveTabDaemonClient
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
@@ -150,21 +148,21 @@ export async function primeAdditionalPage(page: Page): Promise<void> {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
({ daemon: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
if (disableValue === nonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
},
{ daemon, preferences, seedNonce },
);
@@ -175,11 +173,11 @@ export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon, preferences }) => {
({ daemon: seededDaemon, preferences: seededPreferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
@@ -210,13 +208,15 @@ export async function openWorkspaceWithAgents(
}
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
await expect(
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
).toHaveCount(0, {
timeout: 30_000,
});
}
@@ -229,6 +229,24 @@ export async function expectWorkspaceArchiveOutcome(
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
visible: true,
});
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
await closeButton.first().click();
await expectWorkspaceTabHidden(page, agentId);
}
export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise<void> {
await expectWorkspaceTabVisible(page, agentId);
await expect(
page.getByText("This agent is archived").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000,
});
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
@@ -259,6 +277,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function clickSessionRow(page: Page, title: string): Promise<void> {
const row = getSessionRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },

View File

@@ -0,0 +1,41 @@
import { expect, type Page } from "@playwright/test";
function fileExplorerTree(page: Page) {
return page.getByTestId("file-explorer-tree-scroll");
}
function fileExplorerEntry(page: Page, name: string) {
return fileExplorerTree(page).getByText(name, { exact: true }).first();
}
export async function openFileExplorer(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).first().click();
await page.getByTestId("explorer-tab-files").click();
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
}
export async function expandFolder(page: Page, folderName: string): Promise<void> {
await fileExplorerEntry(page, folderName).click();
}
export async function collapseFolder(page: Page, folderName: string): Promise<void> {
await fileExplorerEntry(page, folderName).click();
}
export async function openFileFromExplorer(page: Page, fileName: string): Promise<void> {
await fileExplorerEntry(page, fileName).click();
}
export async function expectExplorerEntryVisible(page: Page, name: string): Promise<void> {
await expect(fileExplorerEntry(page, name)).toBeVisible({ timeout: 30_000 });
}
export async function expectExplorerEntryHidden(page: Page, name: string): Promise<void> {
await expect(fileExplorerEntry(page, name)).toBeHidden({ timeout: 30_000 });
}
export async function expectFileTabOpen(page: Page, filePath: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-file_${filePath}`).first()).toBeVisible({
timeout: 30_000,
});
}

View File

@@ -23,16 +23,18 @@ export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
/** Wait for the workspace tab bar to be visible. */
export async function waitForTabBar(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000,
});
}
/** Return all tab test IDs currently in the tab bar. */
export async function getTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
);
const tabs = page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ visible: true });
const count = await tabs.count();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
@@ -55,6 +57,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
)
.filter({ visible: true })
.first();
if (await activeTab.isVisible().catch(() => false)) {
return activeTab.getAttribute("data-testid");
@@ -67,14 +70,14 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab");
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
export async function clickNewTerminalButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal");
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
@@ -88,32 +91,36 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
export async function waitForLauncherPanel(page: Page): Promise<void> {
export async function waitForLauncherPanel(_page: Page): Promise<void> {
// No-op: the launcher panel no longer exists.
}
/** Assert the new agent tab button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible();
await expect(
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the new terminal button is visible in the tab bar. */
export async function assertTerminalTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-terminal").first()).toBeVisible();
await expect(
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
).toBeVisible();
}
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Click the new agent tab button to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab");
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button to create a terminal tab. */
export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal");
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
@@ -131,13 +138,14 @@ export async function waitForTabWithTitle(
page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ hasText: matcher })
.filter({ visible: true })
.first(),
).toBeVisible({ timeout });
}
/** Assert the new agent tab button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-agent-tab");
const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}

View File

@@ -17,21 +17,21 @@ type NewWorkspaceDaemonClient = Pick<
| "openProject"
>;
type NewWorkspaceDaemonClientConfig = {
interface NewWorkspaceDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
export type OpenedProject = {
export interface OpenedProject {
workspaceId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
};
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -49,9 +49,7 @@ function getDaemonWsUrl(): string {
}
async function loadDaemonClientConstructor(): Promise<
new (
config: NewWorkspaceDaemonClientConfig,
) => NewWorkspaceDaemonClient
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
@@ -153,7 +151,7 @@ export async function createWorktreeViaDaemon(
};
}
export async function clickNewWorkspaceButton(
export async function openNewWorkspaceComposer(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
@@ -168,7 +166,13 @@ export async function clickNewWorkspaceButton(
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
@@ -176,6 +180,44 @@ export async function clickNewWorkspaceButton(
await createButton.click();
}
export async function openStartingRefPicker(page: Page): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
}
export async function selectBranchInPicker(page: Page, name: string): Promise<void> {
const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`);
await expect(branchRow).toBeVisible({ timeout: 30_000 });
await branchRow.click();
}
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
await expect(prRow).toBeVisible({ timeout: 30_000 });
await prRow.click();
}
export async function expectStartingRefPickerTriggerPr(
page: Page,
input: { number: number; title: string; headRef: string },
): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toContainText(`#${input.number}`);
await expect(trigger).toContainText(input.title);
await expect(trigger).not.toContainText(input.headRef);
}
export async function expectComposerGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pills = page.getByTestId("composer-github-attachment-pill");
await expect(pills).toHaveCount(1);
await expect(pills.first()).toContainText(`#${input.number}`);
await expect(pills.first()).toContainText(input.title);
}
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },

View File

@@ -1,20 +1,20 @@
import WebSocket from "ws";
import { WebSocket } from "ws";
type WebSocketLike = {
interface WebSocketLike {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void;
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
addEventListener?: (event: string, listener: (event: any) => void) => void;
removeEventListener?: (event: string, listener: (event: any) => void) => void;
onopen?: ((event: any) => void) | null;
onclose?: ((event: any) => void) | null;
onerror?: ((event: any) => void) | null;
onmessage?: ((event: any) => void) | null;
};
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
addEventListener?: (event: string, listener: (event: unknown) => void) => void;
removeEventListener?: (event: string, listener: (event: unknown) => void) => void;
onopen?: ((event: unknown) => void) | null;
onclose?: ((event: unknown) => void) | null;
onerror?: ((event: unknown) => void) | null;
onmessage?: ((event: unknown) => void) | null;
}
export type NodeWebSocketFactory = (
url: string,

View File

@@ -0,0 +1,131 @@
import { existsSync } from "node:fs";
import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
export interface PaseoHomeMetadataForkResult {
sourceHome: string;
targetHome: string;
agentFiles: number;
agentBytes: number;
projectFiles: number;
projectBytes: number;
copiedFiles: number;
copiedBytes: number;
skippedMissing: string[];
}
interface CopyStats {
files: number;
bytes: number;
skippedMissing: string[];
}
export function resolvePaseoHomePath(value: string): string {
if (value === "~") {
return homedir();
}
if (value.startsWith("~/")) {
return path.join(homedir(), value.slice(2));
}
return path.resolve(value);
}
async function copyJsonTree(sourceDir: string, targetDir: string): Promise<CopyStats> {
if (!existsSync(sourceDir)) {
return { files: 0, bytes: 0, skippedMissing: [sourceDir] };
}
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
const entries = await readdir(sourceDir, { withFileTypes: true });
await mkdir(targetDir, { recursive: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
const nested = await copyJsonTree(sourcePath, targetPath);
stats.files += nested.files;
stats.bytes += nested.bytes;
stats.skippedMissing.push(...nested.skippedMissing);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".json")) {
continue;
}
await mkdir(path.dirname(targetPath), { recursive: true });
await copyFile(sourcePath, targetPath);
const fileStat = await stat(sourcePath);
stats.files += 1;
stats.bytes += fileStat.size;
}
return stats;
}
async function copyProjectRegistryFiles(
sourceHome: string,
targetHome: string,
): Promise<CopyStats> {
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
const sourceProjectsDir = path.join(sourceHome, "projects");
const targetProjectsDir = path.join(targetHome, "projects");
await mkdir(targetProjectsDir, { recursive: true });
for (const fileName of ["projects.json", "workspaces.json"]) {
const sourcePath = path.join(sourceProjectsDir, fileName);
const targetPath = path.join(targetProjectsDir, fileName);
if (!existsSync(sourcePath)) {
stats.skippedMissing.push(sourcePath);
continue;
}
await copyFile(sourcePath, targetPath);
const fileStat = await stat(sourcePath);
stats.files += 1;
stats.bytes += fileStat.size;
}
return stats;
}
export async function forkPaseoHomeMetadata(input: {
sourceHome: string;
targetHome: string;
}): Promise<PaseoHomeMetadataForkResult> {
const sourceHome = resolvePaseoHomePath(input.sourceHome);
const targetHome = resolvePaseoHomePath(input.targetHome);
if (sourceHome === targetHome) {
throw new Error("Refusing to fork Paseo metadata onto the same PASEO_HOME.");
}
await mkdir(targetHome, { recursive: true });
// Reset only the copied metadata surface. In particular, do not copy or remove
// worktrees here: forked workspace records should continue to point at the
// original checkout/worktree paths from the source home.
await rm(path.join(targetHome, "agents"), { recursive: true, force: true });
await rm(path.join(targetHome, "projects", "projects.json"), { force: true });
await rm(path.join(targetHome, "projects", "workspaces.json"), { force: true });
const agents = await copyJsonTree(
path.join(sourceHome, "agents"),
path.join(targetHome, "agents"),
);
const projects = await copyProjectRegistryFiles(sourceHome, targetHome);
return {
sourceHome,
targetHome,
agentFiles: agents.files,
agentBytes: agents.bytes,
projectFiles: projects.files,
projectBytes: projects.bytes,
copiedFiles: agents.files + projects.files,
copiedBytes: agents.bytes + projects.bytes,
skippedMissing: [...agents.skippedMissing, ...projects.skippedMissing],
};
}

View File

@@ -0,0 +1,233 @@
import { expect, type Page } from "../fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
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";
const E2E_KEY = "@paseo:e2e";
interface SavedHostInput {
serverId: string;
label: string;
endpoint: string;
}
export function startupScenario(page: Page) {
return new StartupScenario(page);
}
class StartupScenario {
private readonly page: Page;
private savedHosts: SavedHostInput[] = [];
private desktopBridge = false;
private blockedEndpointPorts = new Set<string>();
private viewport: { width: number; height: number } | null = null;
constructor(page: Page) {
this.page = page;
}
withMobileViewport(): this {
this.viewport = { width: 390, height: 844 };
return this;
}
withSavedHost(input: SavedHostInput): this {
this.savedHosts.push(input);
const port = input.endpoint.match(/:(\d+)$/)?.[1];
if (port) {
this.blockedEndpointPorts.add(port);
}
return this;
}
withPendingDesktopDaemon(): this {
this.desktopBridge = true;
return this;
}
withBlockedPort(port: string): this {
this.blockedEndpointPorts.add(port);
return this;
}
async openRoot(): Promise<StartupAssertions> {
await this.prepare();
await this.page.goto("/");
return new StartupAssertions(this.page);
}
async openHostWorkspace(input: {
serverId: string;
workspaceId: string;
}): Promise<StartupAssertions> {
await this.prepare();
await this.page.goto(
`/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`,
);
return new StartupAssertions(this.page);
}
private async prepare(): Promise<void> {
if (this.viewport) {
await this.page.setViewportSize(this.viewport);
}
if (this.desktopBridge) {
await installPendingDesktopBridge(this.page);
}
for (const port of this.blockedEndpointPorts) {
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
});
}
if (this.savedHosts.length === 0) {
return;
}
// Let the shared fixture create its seed nonce, then opt out of that seed for
// the next navigation so this scenario owns the stored host registry.
await this.page.goto("/");
const nowIso = new Date().toISOString();
const registry = this.savedHosts.map((host) =>
buildStoredHost({
serverId: host.serverId,
endpoint: host.endpoint,
label: host.label,
nowIso,
}),
);
const firstHost = registry[0];
if (!firstHost) {
throw new Error("Expected at least one startup test host.");
}
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
await this.page.evaluate(
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
const nonce = localStorage.getItem(keys.seedNonce);
if (!nonce) {
throw new Error("Expected e2e seed nonce before overriding startup registry.");
}
localStorage.setItem(keys.e2e, "1");
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,
e2e: E2E_KEY,
registry: REGISTRY_KEY,
seedNonce: SEED_NONCE_KEY,
},
registry,
createAgentPreferences,
},
);
}
}
class StartupAssertions {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async expectsReconnectWelcome(): Promise<this> {
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
return this;
}
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
return this;
}
async expectsNoLocalServerStartupCopy(): Promise<this> {
await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount(
0,
);
return this;
}
async expectsDesktopDaemonStartup(): Promise<this> {
await expect(this.page.getByTestId("startup-splash")).toBeVisible({
timeout: 15_000,
});
return this;
}
async expectsSidebarHidden(): Promise<this> {
await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0);
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
return this;
}
async expectsNoUndefinedRoute(): Promise<this> {
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
return this;
}
}
async function installPendingDesktopBridge(page: Page): Promise<void> {
await page.addInitScript(() => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
if (command === "start_desktop_daemon") {
await new Promise(() => {
// Keep the daemon in the startup phase until the test ends.
});
}
if (command === "desktop_daemon_status") {
return {
serverId: "srv_desktop_pending",
status: "starting",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
if (command === "desktop_daemon_logs") {
return { logPath: "", contents: "" };
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
});
}
function buildStoredHost(input: {
serverId: string;
endpoint: string;
label: string;
nowIso: string;
}) {
return buildSeededHost(input);
}
function buildStoredCreateAgentPreferences(serverId: string) {
return buildCreateAgentPreferences(serverId);
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,98 @@
import type { Page } from "@playwright/test";
import { createTempGitRepo } from "./workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./terminal-perf";
interface TempRepo {
path: string;
cleanup: () => Promise<void>;
}
export interface TerminalInstance {
id: string;
name: string;
cwd: string;
}
export class TerminalE2EHarness {
readonly client: TerminalPerfDaemonClient;
readonly tempRepo: TempRepo;
readonly workspaceId: string;
private constructor(input: {
client: TerminalPerfDaemonClient;
tempRepo: TempRepo;
workspaceId: string;
}) {
this.client = input.client;
this.tempRepo = input.tempRepo;
this.workspaceId = input.workspaceId;
}
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
const tempRepo = await createTempGitRepo(input.tempPrefix);
const client = await connectTerminalClient();
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) {
await client.close().catch(() => {});
await tempRepo.cleanup().catch(() => {});
throw new Error(seedResult.error ?? "Failed to seed workspace");
}
return new TerminalE2EHarness({
client,
tempRepo,
workspaceId: seedResult.workspace.id,
});
}
async cleanup(): Promise<void> {
await this.client.close().catch(() => {});
await this.tempRepo.cleanup().catch(() => {});
}
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
return result.terminal;
}
async killTerminal(terminalId: string): Promise<void> {
await this.client.killTerminal(terminalId).catch(() => {});
}
async openTerminal(page: Page, input: { terminalId: string }): Promise<void> {
await navigateToTerminal(page, {
workspaceId: this.workspaceId,
terminalId: input.terminalId,
});
}
terminalSurface(page: Page) {
return page.locator('[data-testid="terminal-surface"]');
}
async setupPrompt(page: Page, sentinel?: string): Promise<void> {
await setupDeterministicPrompt(page, sentinel);
}
}
export async function withTerminalInApp<T>(
page: Page,
harness: TerminalE2EHarness,
input: { name: string },
fn: (terminal: TerminalInstance) => Promise<T>,
): Promise<T> {
const terminal = await harness.createTerminal({ name: input.name });
try {
await harness.openTerminal(page, { terminalId: terminal.id });
return await fn(terminal);
} finally {
await harness.killTerminal(terminal.id);
}
}

View File

@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
export interface TerminalPerfDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
@@ -19,6 +19,17 @@ export type TerminalPerfDaemonClient = {
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 }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
@@ -30,7 +41,7 @@ export type TerminalPerfDaemonClient = {
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;
@@ -48,17 +59,15 @@ function getServerId(): string {
return serverId;
}
type TerminalPerfDaemonClientConfig = {
interface TerminalPerfDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
async function loadDaemonClientConstructor(): Promise<
new (
config: TerminalPerfDaemonClientConfig,
) => TerminalPerfDaemonClient
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
@@ -95,7 +104,19 @@ function buildWorkspaceUrl(workspaceId: string): string {
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
const term = (
window as Window & {
__paseoTerminal?: {
buffer: {
active: {
length: number;
getLine: (i: number) => { translateToString: (trim: boolean) => string } | null;
};
};
onWriteParsed: (cb: () => void) => { dispose: () => void };
};
}
).__paseoTerminal;
if (!term) {
return "";
}
@@ -186,10 +207,10 @@ export async function setupDeterministicPrompt(page: Page, sentinel?: string): P
await page.waitForTimeout(300);
}
export type LatencySample = {
export interface LatencySample {
char: string;
latencyMs: number;
};
}
/**
* Measures keystroke echo round-trip latency.
@@ -200,12 +221,16 @@ export type LatencySample = {
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
const win = window as Window & {
__paseoTerminal?: { onWriteParsed: (cb: () => void) => { dispose: () => void } };
__perfKeystroke?: { promise: Promise<number> | null };
};
if (!win.__paseoTerminal) {
throw new Error("__paseoTerminal not available");
}
const term = win.__paseoTerminal;
const state = ((window as any).__perfKeystroke = {
const state = (win.__perfKeystroke = {
promise: null as Promise<number> | null,
});
@@ -231,7 +256,11 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
return page.evaluate(
() =>
(window as unknown as { __perfKeystroke: { promise: Promise<number> } }).__perfKeystroke
.promise,
);
}
export function computePercentile(samples: number[], p: number): number {

View File

@@ -0,0 +1,713 @@
import type { Page } from "@playwright/test";
export interface TerminalRenderProbeSnapshot {
setCount: number;
unsetCount: number;
writeCount: number;
resetWrites: number;
clearScreenWrites: number;
altEnterWrites: number;
altExitWrites: number;
events: TerminalRenderProbeEvent[];
frames: TerminalFrame[];
}
export interface TerminalRenderProbeEvent {
at: number;
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
preview?: string;
}
export interface TerminalFrame {
at: number;
rowCount: number;
nonEmptyRows: number;
firstNonEmptyRow: number | null;
text: string;
topText: string;
}
export type TerminalRenderProbeSummary = Omit<TerminalRenderProbeSnapshot, "frames"> & {
frameCount: number;
};
export interface TerminalKeystrokeStressReport {
inputTextLength: number;
keydownCount: number;
inputFrameCount: number;
outputFrameCount: number;
textMessageFrameCount: number;
textMessagePayloadBytes: number;
largeTextMessageCount: number;
largestTextMessageBytes: number;
agentStreamTextMessageCount: number;
agentStreamTextMessagePayloadBytes: number;
largeAgentStreamTextMessageCount: number;
largestAgentStreamTextMessageBytes: number;
appEventCount: number;
appEventCounts: Record<string, number>;
runtimeMaxQueueDepth: number;
xtermWriteCount: number;
inputFramePayloadBytes: number;
outputFramePayloadBytes: number;
keydownToInputFrameMs: LatencyStats | null;
inputFrameToOutputFrameMs: LatencyStats | null;
appBinaryReceivedToFrameDecodedMs: LatencyStats | null;
appFrameDecodedToTerminalEmitMs: LatencyStats | null;
appTerminalEmitListenerDurationMs: LatencyStats | null;
appTerminalEmitToStreamControllerOutputMs: LatencyStats | null;
appStreamControllerDecodeToOnOutputMs: LatencyStats | null;
appStreamControllerToEmulatorWriteMs: LatencyStats | null;
appEmulatorWriteToRuntimeEnqueuedMs: LatencyStats | null;
appRuntimeEnqueuedToOperationStartMs: LatencyStats | null;
appRuntimeOperationStartToXtermWriteMs: LatencyStats | null;
appRuntimeXtermWriteToCommitMs: LatencyStats | null;
appBinaryReceivedToRuntimeEnqueuedMs: LatencyStats | null;
appBinaryReceivedToRuntimeOperationStartMs: LatencyStats | null;
appBinaryReceivedToXtermCommitMs: LatencyStats | null;
outputFrameToXtermWriteMs: LatencyStats | null;
xtermWriteDurationMs: LatencyStats | null;
keydownToXtermCommitMs: LatencyStats | null;
firstKeydownAt: number | null;
lastXtermCommitAt: number | null;
}
export interface LatencyStats {
count: number;
minMs: number;
p50Ms: number;
p95Ms: number;
maxMs: number;
avgMs: number;
}
export async function installTerminalRenderProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
interface ProbeTerm {
write?: (data: string | Uint8Array, callback?: () => void) => void;
__paseoRenderProbeWriteWrapped?: boolean;
}
interface ProbeState {
term: ProbeTerm | undefined;
setCount: number;
unsetCount: number;
writeCount: number;
resetWrites: number;
clearScreenWrites: number;
altEnterWrites: number;
altExitWrites: number;
events: TerminalRenderProbeEvent[];
frames: TerminalFrame[];
rafId: number | null;
sampleUntil: number;
reset: () => void;
snapshot: () => TerminalRenderProbeSnapshot;
startSampling: (durationMs: number) => void;
}
const win = window as unknown as Record<string, unknown> & {
__terminalRenderProbe?: ProbeState;
__paseoTerminal?: ProbeTerm;
};
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
const getExisting = () =>
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
const probe: ProbeState = {
term: getExisting(),
setCount: 0,
unsetCount: 0,
writeCount: 0,
resetWrites: 0,
clearScreenWrites: 0,
altEnterWrites: 0,
altExitWrites: 0,
events: [],
frames: [],
rafId: null,
sampleUntil: 0,
reset() {
this.setCount = 0;
this.unsetCount = 0;
this.writeCount = 0;
this.resetWrites = 0;
this.clearScreenWrites = 0;
this.altEnterWrites = 0;
this.altExitWrites = 0;
this.events = [];
this.frames = [];
},
snapshot() {
return {
setCount: this.setCount,
unsetCount: this.unsetCount,
writeCount: this.writeCount,
resetWrites: this.resetWrites,
clearScreenWrites: this.clearScreenWrites,
altEnterWrites: this.altEnterWrites,
altExitWrites: this.altExitWrites,
events: this.events,
frames: this.frames,
};
},
startSampling(durationMs: number) {
this.sampleUntil = performance.now() + durationMs;
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
}
const sample = () => {
const rows = Array.from(document.querySelectorAll(".xterm-rows > div")).map(
(row) => row.textContent ?? "",
);
const nonEmptyRows = rows.filter((row) => row.trim().length > 0);
const firstNonEmptyRow = rows.findIndex((row) => row.trim().length > 0);
this.frames.push({
at: performance.now(),
rowCount: rows.length,
nonEmptyRows: nonEmptyRows.length,
firstNonEmptyRow: firstNonEmptyRow === -1 ? null : firstNonEmptyRow,
text: rows.join("\n"),
topText: rows.slice(0, 3).join("\n"),
});
if (performance.now() < this.sampleUntil) {
this.rafId = requestAnimationFrame(sample);
} else {
this.rafId = null;
}
};
this.rafId = requestAnimationFrame(sample);
},
};
Object.defineProperty(win, "__terminalRenderProbe", {
configurable: true,
value: probe,
});
Object.defineProperty(win, "__paseoTerminal", {
configurable: true,
get() {
return probe.term;
},
set(next: ProbeTerm | undefined) {
if (next === undefined) {
probe.unsetCount += 1;
probe.events.push({ at: performance.now(), type: "unset" });
probe.term = next;
return;
}
probe.setCount += 1;
probe.events.push({ at: performance.now(), type: "set" });
probe.term = next;
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
next.write = (data: string | Uint8Array, callback?: () => void) => {
const text =
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
probe.writeCount += 1;
const preview = text
.replaceAll("\u001b", "\\x1b")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.slice(0, 160);
if (text.includes("\u001bc")) {
probe.resetWrites += 1;
probe.events.push({ at: performance.now(), type: "reset-write", preview });
}
if (text.includes("\u001b[2J")) {
probe.clearScreenWrites += 1;
probe.events.push({ at: performance.now(), type: "clear-write", preview });
}
if (text.includes("\u001b[?1049h")) {
probe.altEnterWrites += 1;
probe.events.push({ at: performance.now(), type: "alt-enter-write", preview });
}
if (text.includes("\u001b[?1049l")) {
probe.altExitWrites += 1;
probe.events.push({ at: performance.now(), type: "alt-exit-write", preview });
}
return originalWrite(data, callback);
};
next.__paseoRenderProbeWriteWrapped = true;
}
},
});
});
}
interface TerminalRenderProbeWindow {
__terminalRenderProbe: {
reset: () => void;
startSampling: (durationMs: number) => void;
snapshot: () => TerminalRenderProbeSnapshot;
};
}
export async function resetTerminalRenderProbe(page: Page): Promise<void> {
await page.evaluate(() => {
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.reset();
});
}
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
await page.evaluate((ms) => {
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.startSampling(ms);
}, durationMs);
}
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
return page.evaluate(() =>
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.snapshot(),
);
}
export async function terminalVisibleText(page: Page): Promise<string> {
return page.evaluate(() =>
Array.from(document.querySelectorAll(".xterm-rows > div"))
.map((row) => row.textContent ?? "")
.join("\n"),
);
}
export function summarizeTerminalRenderProbe(
probe: TerminalRenderProbeSnapshot,
): TerminalRenderProbeSummary {
return {
setCount: probe.setCount,
unsetCount: probe.unsetCount,
writeCount: probe.writeCount,
resetWrites: probe.resetWrites,
clearScreenWrites: probe.clearScreenWrites,
altEnterWrites: probe.altEnterWrites,
altExitWrites: probe.altExitWrites,
events: probe.events,
frameCount: probe.frames.length,
};
}
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
interface TimedTextEvent {
at: number;
text: string;
bytes: number;
}
interface TimedTextMessageEvent {
at: number;
bytes: number;
kind: string | null;
}
interface XtermWriteEvent {
at: number;
committedAt: number | null;
text: string;
bytes: number;
}
interface AppProbeEvent {
type: string;
at: number;
bytes?: number;
queueDepth?: number;
}
interface StressProbeState {
keydowns: Array<{ at: number; key: string }>;
inputFrames: TimedTextEvent[];
outputFrames: TimedTextEvent[];
textMessageFrames: TimedTextMessageEvent[];
xtermWrites: XtermWriteEvent[];
appEvents: AppProbeEvent[];
reset: () => void;
report: (inputText: string) => TerminalKeystrokeStressReport;
}
const INPUT_OPCODE = 0x02;
const OUTPUT_OPCODE = 0x01;
const decoder = new TextDecoder();
function bytesFrom(data: unknown): Uint8Array | null {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}
function eventDataBytes(data: unknown): Promise<Uint8Array | null> {
const bytes = bytesFrom(data);
if (bytes) return Promise.resolve(bytes);
if (typeof Blob !== "undefined" && data instanceof Blob) {
return data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
}
return Promise.resolve(null);
}
function frameText(bytes: Uint8Array): string {
return decoder.decode(bytes.slice(2));
}
function eventDataText(data: unknown): Promise<string | null> {
if (typeof data === "string") {
return Promise.resolve(data);
}
if (typeof Blob !== "undefined" && data instanceof Blob) {
return data.text();
}
return Promise.resolve(null);
}
function textMessageKind(text: string): string | null {
try {
const parsed = JSON.parse(text) as {
type?: unknown;
message?: {
type?: unknown;
};
};
if (typeof parsed.type !== "string") {
return null;
}
if (typeof parsed.message?.type === "string") {
return `${parsed.type}:${parsed.message.type}`;
}
return parsed.type;
} catch {
return null;
}
}
function summarize(values: number[]): LatencyStats | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const percentile = (p: number) => {
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
};
const total = values.reduce((sum, value) => sum + value, 0);
const round2 = (value: number) => Math.round(value * 100) / 100;
return {
count: values.length,
minMs: round2(sorted[0] ?? 0),
p50Ms: round2(percentile(50)),
p95Ms: round2(percentile(95)),
maxMs: round2(sorted[sorted.length - 1] ?? 0),
avgMs: round2(total / values.length),
};
}
function firstAtOrAfter<T extends { at: number }>(events: T[], at: number): T | null {
return events.find((event) => event.at >= at) ?? null;
}
function firstCommitAtOrAfter(events: XtermWriteEvent[], at: number): XtermWriteEvent | null {
return (
events.find((event) => typeof event.committedAt === "number" && event.committedAt >= at) ??
null
);
}
function countByType(events: AppProbeEvent[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const event of events) {
counts[event.type] = (counts[event.type] ?? 0) + 1;
}
return counts;
}
function appEventsOf(type: string, events: AppProbeEvent[]): AppProbeEvent[] {
return events.filter((event) => event.type === type);
}
function latencyByIndex(from: AppProbeEvent[], to: AppProbeEvent[]): number[] {
const count = Math.min(from.length, to.length);
const values: number[] = [];
for (let index = 0; index < count; index += 1) {
values.push(to[index]!.at - from[index]!.at);
}
return values;
}
const probe: StressProbeState = {
keydowns: [],
inputFrames: [],
outputFrames: [],
textMessageFrames: [],
xtermWrites: [],
appEvents: [],
reset() {
this.keydowns = [];
this.inputFrames = [];
this.outputFrames = [];
this.textMessageFrames = [];
this.xtermWrites = [];
this.appEvents = [];
},
report(inputText: string) {
const binaryReceived = appEventsOf("daemon-client-binary-received", this.appEvents);
const frameDecoded = appEventsOf("daemon-client-frame-decoded", this.appEvents);
const terminalEmit = appEventsOf("daemon-client-terminal-emit", this.appEvents);
const terminalEmitted = appEventsOf("daemon-client-terminal-emitted", this.appEvents);
const streamControllerOutput = appEventsOf("stream-controller-output", this.appEvents);
const streamControllerOnOutput = appEventsOf("stream-controller-on-output", this.appEvents);
const emulatorWriteOutput = appEventsOf("terminal-emulator-write-output", this.appEvents);
const runtimeWriteEnqueued = appEventsOf("runtime-write-enqueued", this.appEvents);
const runtimeOperationStart = appEventsOf("runtime-operation-start", this.appEvents);
const runtimeXtermWrite = appEventsOf("runtime-xterm-write", this.appEvents);
const runtimeXtermCommitted = appEventsOf("runtime-xterm-committed", this.appEvents);
const keydownToInputFrame = this.keydowns
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
.filter((at): at is number => at !== null)
.map((at, index) => at - this.keydowns[index]!.at);
const inputFrameToOutputFrame = this.inputFrames
.map((input) => {
const output = firstAtOrAfter(this.outputFrames, input.at);
return output ? output.at - input.at : null;
})
.filter((value): value is number => value !== null);
const outputFrameToXtermWrite = this.outputFrames
.map((output) => {
const write = firstAtOrAfter(this.xtermWrites, output.at);
return write ? write.at - output.at : null;
})
.filter((value): value is number => value !== null);
const xtermWriteDurations = this.xtermWrites
.map((write) => (write.committedAt === null ? null : write.committedAt - write.at))
.filter((value): value is number => value !== null);
const keydownToXtermCommit = this.keydowns
.map((keydown) => {
const write = firstCommitAtOrAfter(this.xtermWrites, keydown.at);
return write?.committedAt ? write.committedAt - keydown.at : null;
})
.filter((value): value is number => value !== null);
return {
inputTextLength: inputText.length,
keydownCount: this.keydowns.length,
inputFrameCount: this.inputFrames.length,
outputFrameCount: this.outputFrames.length,
textMessageFrameCount: this.textMessageFrames.length,
textMessagePayloadBytes: this.textMessageFrames.reduce(
(sum, frame) => sum + frame.bytes,
0,
),
largeTextMessageCount: this.textMessageFrames.filter((frame) => frame.bytes >= 50_000)
.length,
largestTextMessageBytes: Math.max(
0,
...this.textMessageFrames.map((frame) => frame.bytes),
),
agentStreamTextMessageCount: this.textMessageFrames.filter(
(frame) => frame.kind === "session:agent_stream",
).length,
agentStreamTextMessagePayloadBytes: this.textMessageFrames
.filter((frame) => frame.kind === "session:agent_stream")
.reduce((sum, frame) => sum + frame.bytes, 0),
largeAgentStreamTextMessageCount: this.textMessageFrames.filter(
(frame) => frame.kind === "session:agent_stream" && frame.bytes >= 50_000,
).length,
largestAgentStreamTextMessageBytes: Math.max(
0,
...this.textMessageFrames
.filter((frame) => frame.kind === "session:agent_stream")
.map((frame) => frame.bytes),
),
appEventCount: this.appEvents.length,
appEventCounts: countByType(this.appEvents),
runtimeMaxQueueDepth: Math.max(
0,
...this.appEvents
.map((event) => event.queueDepth)
.filter((value): value is number => typeof value === "number"),
),
xtermWriteCount: this.xtermWrites.length,
inputFramePayloadBytes: this.inputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
outputFramePayloadBytes: this.outputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
keydownToInputFrameMs: summarize(keydownToInputFrame),
inputFrameToOutputFrameMs: summarize(inputFrameToOutputFrame),
appBinaryReceivedToFrameDecodedMs: summarize(
latencyByIndex(binaryReceived, frameDecoded),
),
appFrameDecodedToTerminalEmitMs: summarize(latencyByIndex(frameDecoded, terminalEmit)),
appTerminalEmitListenerDurationMs: summarize(
latencyByIndex(terminalEmit, terminalEmitted),
),
appTerminalEmitToStreamControllerOutputMs: summarize(
latencyByIndex(terminalEmit, streamControllerOutput),
),
appStreamControllerDecodeToOnOutputMs: summarize(
latencyByIndex(streamControllerOutput, streamControllerOnOutput),
),
appStreamControllerToEmulatorWriteMs: summarize(
latencyByIndex(streamControllerOnOutput, emulatorWriteOutput),
),
appEmulatorWriteToRuntimeEnqueuedMs: summarize(
latencyByIndex(emulatorWriteOutput, runtimeWriteEnqueued),
),
appRuntimeEnqueuedToOperationStartMs: summarize(
latencyByIndex(runtimeWriteEnqueued, runtimeOperationStart),
),
appRuntimeOperationStartToXtermWriteMs: summarize(
latencyByIndex(runtimeOperationStart, runtimeXtermWrite),
),
appRuntimeXtermWriteToCommitMs: summarize(
latencyByIndex(runtimeXtermWrite, runtimeXtermCommitted),
),
appBinaryReceivedToRuntimeEnqueuedMs: summarize(
latencyByIndex(binaryReceived, runtimeWriteEnqueued),
),
appBinaryReceivedToRuntimeOperationStartMs: summarize(
latencyByIndex(binaryReceived, runtimeOperationStart),
),
appBinaryReceivedToXtermCommitMs: summarize(
latencyByIndex(binaryReceived, runtimeXtermCommitted),
),
outputFrameToXtermWriteMs: summarize(outputFrameToXtermWrite),
xtermWriteDurationMs: summarize(xtermWriteDurations),
keydownToXtermCommitMs: summarize(keydownToXtermCommit),
firstKeydownAt: this.keydowns[0]?.at ?? null,
lastXtermCommitAt:
this.xtermWrites
.map((write) => write.committedAt)
.findLast((at): at is number => typeof at === "number") ?? null,
};
},
};
Object.defineProperty(window, "__terminalKeystrokeStressProbe", {
configurable: true,
value: probe,
});
document.addEventListener(
"keydown",
(event) => {
if (event.key.length === 1) {
probe.keydowns.push({ at: performance.now(), key: event.key });
}
},
true,
);
const NativeWebSocket = window.WebSocket;
class InstrumentedWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) {
super(url);
} else {
super(url, protocols);
}
super.addEventListener("message", (event) => {
void eventDataBytes(event.data).then((bytes) => {
if (!bytes || bytes.byteLength < 2 || bytes[0] !== OUTPUT_OPCODE) {
return;
}
probe.outputFrames.push({
at: performance.now(),
text: frameText(bytes),
bytes: bytes.byteLength - 2,
});
return;
});
void eventDataText(event.data).then((text) => {
if (text === null) {
return;
}
probe.textMessageFrames.push({
at: performance.now(),
bytes: new TextEncoder().encode(text).byteLength,
kind: textMessageKind(text),
});
return;
});
});
}
send(data: Parameters<WebSocket["send"]>[0]): void {
const bytes = bytesFrom(data);
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
probe.inputFrames.push({
at: performance.now(),
text: frameText(bytes),
bytes: bytes.byteLength - 2,
});
}
super.send(data);
}
}
Object.defineProperty(InstrumentedWebSocket, "CONNECTING", {
value: NativeWebSocket.CONNECTING,
});
Object.defineProperty(InstrumentedWebSocket, "OPEN", { value: NativeWebSocket.OPEN });
Object.defineProperty(InstrumentedWebSocket, "CLOSING", { value: NativeWebSocket.CLOSING });
Object.defineProperty(InstrumentedWebSocket, "CLOSED", { value: NativeWebSocket.CLOSED });
window.WebSocket = InstrumentedWebSocket as typeof WebSocket;
const existingDescriptor = Object.getOwnPropertyDescriptor(window, "__paseoTerminal");
const getExisting = () =>
existingDescriptor?.get ? existingDescriptor.get.call(window) : existingDescriptor?.value;
let terminal = getExisting();
Object.defineProperty(window, "__paseoTerminal", {
configurable: true,
get() {
return terminal;
},
set(next: {
write?: (data: string | Uint8Array, callback?: () => void) => void;
__paseoKeystrokeProbeWriteWrapped?: boolean;
}) {
terminal = next;
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
next.write = (data: string | Uint8Array, callback?: () => void) => {
const text =
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
const event: XtermWriteEvent = {
at: performance.now(),
committedAt: null,
text,
bytes: text.length,
};
probe.xtermWrites.push(event);
return originalWrite(data, () => {
event.committedAt = performance.now();
callback?.();
});
};
next.__paseoKeystrokeProbeWriteWrapped = true;
}
},
});
});
}
interface TerminalKeystrokeStressProbeWindow {
__terminalKeystrokeStressProbe: {
reset: () => void;
report: (text: string) => TerminalKeystrokeStressReport;
};
}
export async function resetTerminalKeystrokeStressProbe(page: Page): Promise<void> {
await page.evaluate(() => {
(
window as unknown as TerminalKeystrokeStressProbeWindow
).__terminalKeystrokeStressProbe.reset();
});
}
export async function readTerminalKeystrokeStressReport(
page: Page,
inputText: string,
): Promise<TerminalKeystrokeStressReport> {
return page.evaluate(
(text) =>
(
window as unknown as TerminalKeystrokeStressProbeWindow
).__terminalKeystrokeStressProbe.report(text),
inputText,
);
}

View File

@@ -8,7 +8,7 @@ import { gotoAppShell } from "./app";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import type { SessionOutboundMessage } from "@server/shared/messages";
type WorkspaceSetupDaemonClient = {
interface WorkspaceSetupDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
@@ -52,7 +52,7 @@ type WorkspaceSetupDaemonClient = {
error?: string | null;
}>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
};
}
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
@@ -116,7 +116,7 @@ export async function seedProjectForWorkspaceSetup(
}
export function projectNameFromPath(repoPath: string): string {
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
return repoPath.replace(/\/+$/, "").split("/").findLast(Boolean) ?? repoPath;
}
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {

View File

@@ -13,18 +13,30 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
return ids;
}
function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true });
}
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
timeout: 30_000,
});
}
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
const allTabIds = await getWorkspaceTabTestIds(page);
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_"));
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
const count = await tabs.count();
const ids: string[] = [];
for (let index = 0; index < count; index += 1) {
const testId = await tabs.nth(index).getAttribute("data-testid");
if (testId && !ids.includes(testId)) {
ids.push(testId);
}
}
return ids;
}
export async function expectOnlyWorkspaceAgentTabsVisible(
@@ -38,7 +50,7 @@ export async function expectOnlyWorkspaceAgentTabsVisible(
expect(unexpected).toEqual([]);
expect(visible.length).toBe(expected.size);
for (const expectedId of expectedAgentIds) {
await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({
await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({
timeout: 30_000,
});
}

View File

@@ -103,8 +103,8 @@ export async function expectWorkspaceHeader(
page: Page,
input: { title: string; subtitle: string },
): Promise<void> {
const titleLocator = page.getByTestId("workspace-header-title");
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000,

View File

@@ -3,10 +3,11 @@ import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type TempRepo = {
interface TempRepo {
path: string;
branchHeads: Record<string, string>;
cleanup: () => Promise<void>;
};
}
export const createTempGitRepo = async (
prefix = "paseo-e2e-",
@@ -14,6 +15,7 @@ export const createTempGitRepo = async (
withRemote?: boolean;
paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>;
branches?: string[];
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
@@ -47,19 +49,72 @@ export const createTempGitRepo = async (
}
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
const branchHeads: Record<string, string> = {};
const branches = Array.from(new Set(options?.branches ?? []));
for (const branch of branches) {
if (branch !== "main") {
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
cwd: repoPath,
stdio: "ignore",
});
}
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
cwd: repoPath,
stdio: "ignore",
});
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
cwd: repoPath,
stdio: "pipe",
})
.toString()
.trim();
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
}
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
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 main", { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
}
return {
path: repoPath,
branchHeads,
cleanup: async () => {
await rm(repoPath, { recursive: true, force: true });
},
};
};
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
currentBranch: string;
hasAncestor: (ref: string) => boolean;
}> {
const currentBranch = execSync("git branch --show-current", {
cwd: worktreePath,
stdio: "pipe",
})
.toString()
.trim();
return {
currentBranch,
hasAncestor: (ref: string) => {
try {
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
cwd: worktreePath,
stdio: "ignore",
});
return true;
} catch {
return false;
}
},
};
}

View File

@@ -17,7 +17,6 @@ import {
} from "./helpers/launcher";
import {
connectTerminalClient,
waitForTerminalContent,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./helpers/terminal-perf";

View File

@@ -1,5 +1,7 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
@@ -8,9 +10,15 @@ import {
clickNewWorkspaceButton,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
expectComposerGithubAttachmentPill,
expectStartingRefPickerTriggerPr,
openNewWorkspaceComposer,
openStartingRefPicker,
openProjectViaDaemon,
selectBranchInPicker,
selectGitHubPrInPicker,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
@@ -20,6 +28,108 @@ import {
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
async function delayBrowserAgentCreatedStatus(page: Page) {
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 createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
@@ -183,7 +293,7 @@ test.describe("New workspace flow", () => {
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
@@ -240,7 +350,12 @@ test.describe("New workspace flow", () => {
subtitle: openedProject.projectDisplayName,
});
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]');
const activeWorkspaceDeckEntry = page
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
.filter({ visible: true });
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
// Workspace setup may auto-open a setup tab that steals focus,
@@ -249,7 +364,172 @@ test.describe("New workspace flow", () => {
await agentTabs.first().click();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });
await expect(composer).toBeVisible({ timeout: 30_000 });
} finally {
await tempRepo.cleanup();
}
});
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 tempRepo = await createTempGitRepo("new-workspace-optimistic-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
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 openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
await expect(page).toHaveURL(
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
{
timeout: 30_000,
},
);
const activeWorkspaceDeckEntry = page
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
.filter({ visible: true });
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(0);
agentCreatedDelay.release();
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(1, { timeout: 30_000 });
} finally {
agentCreatedDelay.release();
await tempRepo.cleanup();
}
});
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.");
}
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
branches: ["main", "dev"],
});
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 openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
const branchInfo = await readWorktreeBranchInfo({
worktreePath: createdWorkspace.workspaceId,
});
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
} finally {
await tempRepo.cleanup();
}
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, 515);
await expectStartingRefPickerTriggerPr(page, {
number: 515,
title: "Review selected start ref",
headRef: "feature/start-from-pr",
});
await expectComposerGithubAttachmentPill(page, {
number: 515,
title: "Review selected start ref",
});
} finally {
await tempRepo.cleanup();
}

View File

@@ -0,0 +1,122 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"];
interface ProjectsSettingsProject {
name: string;
path: string;
}
interface ProjectsSettingsFixtures {
editableProject: ProjectsSettingsProject;
}
const initialPaseoConfig = {
worktree: {
setup: ["echo initial setup"],
teardown: "echo cleanup",
customWorktreeField: "preserved",
},
scripts: {
dev: {
command: "npm run dev",
type: "server",
port: 3000,
customScriptField: "preserved",
},
},
customTopLevelField: "preserved",
};
const test = base.extend<ProjectsSettingsFixtures>({
editableProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-", {
paseoConfig: initialPaseoConfig,
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
});
await client.close();
await repo.cleanup();
},
});
async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
}
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 30_000,
});
}
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page
.getByRole("textbox", { name: "Worktree setup commands" })
.fill(setupCommands.join("\n"));
}
async function saveProjectConfig(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
await expect
.poll(
async () => {
const contents = await readProjectConfigFile(project);
return JSON.parse(contents) as unknown;
},
{
timeout: 30_000,
},
)
.toMatchObject({
worktree: {
setup: updatedSetup,
teardown: initialPaseoConfig.worktree.teardown,
customWorktreeField: initialPaseoConfig.worktree.customWorktreeField,
},
scripts: {
dev: {
command: initialPaseoConfig.scripts.dev.command,
type: initialPaseoConfig.scripts.dev.type,
port: initialPaseoConfig.scripts.dev.port,
customScriptField: initialPaseoConfig.scripts.dev.customScriptField,
},
},
customTopLevelField: initialPaseoConfig.customTopLevelField,
});
const savedConfig = await readProjectConfigFile(project);
expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`);
}
async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<string> {
return readFile(path.join(project.path, "paseo.json"), "utf8");
}
test.describe("Projects settings", () => {
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await editWorktreeSetup(page, updatedSetup);
await saveProjectConfig(page);
await expectProjectConfigSaved(editableProject);
});
});

View File

@@ -23,6 +23,12 @@ async function openHostPage(page: Page, serverId: string) {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
async function expectHostLabelHeader(page: Page) {
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
}
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
@@ -34,16 +40,8 @@ test.describe("Settings host page", () => {
await openSettings(page);
await openHostPage(page, serverId);
// Label renders as a title with a pencil edit affordance; the input is hidden until edit.
await expect(page.getByTestId("host-page-label-card")).toBeVisible();
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-label-card").getByText(TEST_HOST_LABEL, { exact: true }),
).toBeVisible();
// Desktop detail pane shows a ScreenHeader with the host label.
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
await expectHostLabelHeader(page);
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
const connectionsCard = page.getByTestId("host-page-connections-card");
@@ -55,8 +53,7 @@ test.describe("Settings host page", () => {
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
await expect(injectMcpCard).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "On", exact: true })).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "Off", exact: true })).toBeVisible();
await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
@@ -124,7 +121,7 @@ test.describe("Settings host page", () => {
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expect(page.getByTestId("host-page-label-card")).toBeVisible();
await expectHostLabelHeader(page);
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
});
@@ -132,13 +129,10 @@ test.describe("Settings host page", () => {
const serverId = getSeededServerId();
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
// seeded host to the local daemon. `manageBuiltInDaemon: false` bypasses
// the desktop bootstrap flow so only the sidebar's status query runs.
// seeded host to the local daemon. `manageBuiltInDaemon: false` (returned
// from get_desktop_settings) bypasses the desktop bootstrap flow so only
// the sidebar's status query runs against the seeded test daemon.
await page.addInitScript((localServerId) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ theme: "auto", manageBuiltInDaemon: false, sendBehavior: "interrupt" }),
);
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
@@ -155,6 +149,12 @@ test.describe("Settings host page", () => {
error: null,
};
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
};
}
return null;
},
getPendingOpenProject: async () => null,

View File

@@ -59,6 +59,35 @@ test.describe("Settings sidebar navigation", () => {
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
});
test("direct connection advanced URI round-trips SSL and password into the form", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-add-host").click();
await page.getByRole("button", { name: "Direct connection" }).click();
await page.getByTestId("direct-host-advanced-toggle").click();
await page
.getByTestId("direct-host-uri-input")
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test");
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443");
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret");
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -93,6 +122,15 @@ test.describe("Settings — compact master-detail", () => {
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
async function expectCompactSettingsRootList(page: Page) {
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);
}
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await openCompactSettingsRoot(page);
@@ -110,12 +148,12 @@ test.describe("Settings — compact master-detail", () => {
).toBeVisible();
// Section detail content is NOT rendered at the root.
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.getByTestId("host-page-label-card")).toHaveCount(0);
await expectCompactSettingsRootList(page);
// Root shows the menu header, not a back button.
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
await expect(rootBackButton).toBeVisible();
await rootBackButton.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
@@ -144,9 +182,8 @@ test.describe("Settings — compact master-detail", () => {
await expect(page).toHaveURL(/\/settings\/about$/);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
await expectCompactSettingsRootList(page);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {

View File

@@ -0,0 +1,172 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import { getActiveTabTestId, waitForTabBar } 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;
}
async function pressSettingsToggleShortcut(page: Page) {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
}
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
if (!raw) {
return null;
}
return (JSON.parse(raw) as { sendBehavior?: string }).sendBehavior ?? null;
})
.toBe(expected);
}
async function openAgentRouteAndExpectFocused(input: {
page: Page;
serverId: string;
workspaceId: string;
agentId: string;
}) {
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
await input.page.goto(
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
);
await input.page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForTabBar(input.page);
await expect(
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
}
test.describe("Settings toggle tab regression", () => {
test.describe.configure({ timeout: 180_000 });
test("toggling settings after changing a setting returns to the same workspace tab", async ({
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("settings-toggle-tab-");
const agentIds: string[] = [];
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `settings-toggle-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.getByRole("button", { name: "Queue", exact: true }).click();
await expectSendBehavior(page, "queue");
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
await expectSendBehavior(page, "interrupt");
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
await waitForTabBar(page);
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
await page.reload();
await waitForTabBar(page);
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("agent-route-refresh-");
const agentIds: string[] = [];
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `agent-route-refresh-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
agentId: firstAgent.id,
});
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
agentId: secondAgent.id,
});
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
for (let attempt = 0; attempt < 5; attempt += 1) {
await page.reload();
await waitForTabBar(page);
await expect(
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
}
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,64 @@
import { test } from "./fixtures";
import { startupScenario } from "./helpers/startup-dsl";
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return port;
}
function getE2EServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
test.describe("Startup loading presentation", () => {
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
const startup = await startupScenario(page)
.withMobileViewport()
.withSavedHost({
serverId: "srv_unreachable_mobile",
label: "Dev",
endpoint: "127.0.0.1:45678",
})
.openRoot();
await startup.expectsReconnectWelcome();
await startup.expectsNoSavedHostStatus({ label: "Dev" });
await startup.expectsNoLocalServerStartupCopy();
});
test("desktop daemon bootstrap keeps the local server startup copy desktop-only", async ({
page,
}) => {
const startup = await startupScenario(page)
.withPendingDesktopDaemon()
.withBlockedPort(getE2EDaemonPort())
.openRoot();
await startup.expectsDesktopDaemonStartup();
await startup.expectsSidebarHidden();
await startup.expectsNoUndefinedRoute();
});
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
page,
}) => {
const serverId = getE2EServerId();
const startup = await startupScenario(page)
.withPendingDesktopDaemon()
.withBlockedPort(getE2EDaemonPort())
.openHostWorkspace({
serverId,
workspaceId: "workspace-1",
});
await startup.expectsDesktopDaemonStartup();
await startup.expectsSidebarHidden();
});
});

View File

@@ -0,0 +1,543 @@
import { Buffer } from "node:buffer";
import type { CDPSession, Page, TestInfo } from "@playwright/test";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
type WireDirection = "sent" | "received";
type WirePhase = "startup" | "workspace_clicks";
interface ParsedWireMessage {
type: string | null;
requestId: string | null;
entryCount: number | null;
hasMore: boolean | null;
providerEntries: ProviderSnapshotWireEntry[] | null;
}
type WireFrameRecord = ParsedWireMessage & {
phase: WirePhase;
direction: WireDirection;
bytes: number;
};
interface ProviderSnapshotWireEntry {
provider: string;
status: string | null;
modelCount: number;
modeCount: number;
bytes: number;
}
interface WebSocketFrameEvent {
requestId: string;
response: {
opcode: number;
payloadData: string;
};
}
interface WireSummary {
totalFrames: number;
totalBytes: number;
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
byPhase: Record<
WirePhase,
{
frames: number;
bytes: number;
byType: Array<{ type: string; frames: number; bytes: number }>;
rpcCounts: Array<{ requestType: string; count: number }>;
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
}
>;
fetchPages: Array<{
phase: WirePhase;
type: string;
requestId: string | null;
entries: number | null;
hasMore: boolean | null;
bytes: number;
}>;
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
providerSnapshots: Array<{
phase: WirePhase;
type: string;
requestId: string | null;
totalModels: number;
totalModes: number;
bytes: number;
providers: ProviderSnapshotWireEntry[];
}>;
providerSnapshotTotals: Array<{
phase: WirePhase;
provider: string;
frames: number;
bytes: number;
maxModels: number;
maxModes: number;
statuses: string[];
}>;
fork: {
sourceHome: string | null;
targetHome: string | null;
copiedFiles: number | null;
copiedBytes: number | null;
};
}
function extractWorkspaceTestIds(elements: Element[]): string[] {
const result: string[] = [];
for (const element of elements.slice(0, 3)) {
const value = element.getAttribute("data-testid");
if (value) {
result.push(value);
}
}
return result;
}
class WireMonitor {
private phase: WirePhase = "startup";
private session: CDPSession | null = null;
readonly records: WireFrameRecord[] = [];
async start(page: Page): Promise<void> {
this.session = await page.context().newCDPSession(page);
await this.session.send("Network.enable");
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
this.record("sent", event);
});
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
this.record("received", event);
});
}
setPhase(phase: WirePhase): void {
this.phase = phase;
}
hasCompletedStartupFetches(): boolean {
return (
this.records.some(
(record) =>
record.direction === "received" &&
record.type === "fetch_agents_response" &&
record.hasMore === false,
) &&
this.records.some(
(record) =>
record.direction === "received" &&
record.type === "fetch_workspaces_response" &&
record.hasMore === false,
)
);
}
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
return {
totalFrames: this.records.length,
totalBytes: sumBytes(this.records),
byDirection: {
sent: summarizeDirection(this.records, "sent"),
received: summarizeDirection(this.records, "received"),
},
byPhase: {
startup: summarizePhase(this.records, "startup"),
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
},
fetchPages: this.records
.filter(
(record) =>
record.direction === "received" &&
(record.type === "fetch_agents_response" ||
record.type === "fetch_workspaces_response"),
)
.map((record) => ({
phase: record.phase,
type: record.type ?? "unknown",
requestId: record.requestId,
entries: record.entryCount,
hasMore: record.hasMore,
bytes: record.bytes,
})),
clickedWorkspaces,
providerSnapshots: this.records
.filter((record) => record.direction === "received" && record.providerEntries)
.map((record) => ({
phase: record.phase,
type: record.type ?? "unknown",
requestId: record.requestId,
totalModels: sumProviderModels(record.providerEntries ?? []),
totalModes: sumProviderModes(record.providerEntries ?? []),
bytes: record.bytes,
providers: record.providerEntries ?? [],
})),
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
fork: {
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
},
};
}
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
if (event.response.opcode !== 1) {
this.records.push({
phase: this.phase,
direction,
bytes: Buffer.byteLength(event.response.payloadData),
type: `opcode:${event.response.opcode}`,
requestId: null,
entryCount: null,
hasMore: null,
providerEntries: null,
});
return;
}
this.records.push({
phase: this.phase,
direction,
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
...parseWireMessage(event.response.payloadData),
});
}
}
function parseOptionalNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseWireMessage(payloadData: string): ParsedWireMessage {
try {
const parsed = JSON.parse(payloadData) as unknown;
if (!parsed || typeof parsed !== "object") {
return emptyParsedWireMessage();
}
const envelope = parsed as {
type?: unknown;
message?: unknown;
};
const unwrapped =
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? envelope.message
: parsed;
const message = unwrapped as {
type?: unknown;
requestId?: unknown;
payload?: {
requestId?: unknown;
entries?: unknown;
agents?: unknown;
pageInfo?: { hasMore?: unknown };
};
};
const entries = readMessageEntries(message.payload);
const hasMore = readMessageHasMore(message.payload);
const messageType = typeof message.type === "string" ? message.type : null;
const providerEntries = isProviderSnapshotMessage(messageType)
? parseProviderSnapshotEntries(message.payload)
: null;
return {
type: messageType,
requestId: readMessageRequestId(message),
entryCount: entries ? entries.length : null,
hasMore,
providerEntries,
};
} catch {
return emptyParsedWireMessage();
}
}
function readMessageRequestId(message: {
requestId?: unknown;
payload?: { requestId?: unknown };
}): string | null {
if (typeof message.requestId === "string") {
return message.requestId;
}
if (typeof message.payload?.requestId === "string") {
return message.payload.requestId;
}
return null;
}
function isProviderSnapshotMessage(messageType: string | null): boolean {
return (
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
);
}
function readMessageEntries(
payload:
| {
entries?: unknown;
agents?: unknown;
}
| undefined,
): unknown[] | null {
if (Array.isArray(payload?.entries)) {
return payload.entries;
}
if (Array.isArray(payload?.agents)) {
return payload.agents;
}
return null;
}
function readMessageHasMore(
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
): boolean | null {
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
}
function emptyParsedWireMessage(): ParsedWireMessage {
return {
type: null,
requestId: null,
entryCount: null,
hasMore: null,
providerEntries: null,
};
}
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
if (!payload || typeof payload !== "object") {
return null;
}
const entries = (payload as { entries?: unknown }).entries;
if (!Array.isArray(entries)) {
return null;
}
const providerEntries: ProviderSnapshotWireEntry[] = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object") {
continue;
}
const provider = (entry as { provider?: unknown }).provider;
if (typeof provider !== "string") {
continue;
}
const models = (entry as { models?: unknown }).models;
const modes = (entry as { modes?: unknown }).modes;
const status = (entry as { status?: unknown }).status;
providerEntries.push({
provider,
status: typeof status === "string" ? status : null,
modelCount: Array.isArray(models) ? models.length : 0,
modeCount: Array.isArray(modes) ? modes.length : 0,
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
});
}
return providerEntries.length > 0 ? providerEntries : null;
}
function sumBytes(records: WireFrameRecord[]): number {
return records.reduce((sum, record) => sum + record.bytes, 0);
}
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
const selected = records.filter((record) => record.direction === direction);
return {
frames: selected.length,
bytes: sumBytes(selected),
};
}
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
const selected = records.filter((record) => record.phase === phase);
return {
frames: selected.length,
bytes: sumBytes(selected),
byType: summarizeByType(selected),
rpcCounts: summarizeRpcCounts(selected),
rpcs: summarizeRpcs(selected),
};
}
function summarizeByType(records: WireFrameRecord[]): Array<{
type: string;
frames: number;
bytes: number;
}> {
const byType = new Map<string, { frames: number; bytes: number }>();
for (const record of records) {
const type = record.type ?? "unknown";
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
current.frames += 1;
current.bytes += record.bytes;
byType.set(type, current);
}
return [...byType.entries()]
.map(([type, value]) => Object.assign({ type }, value))
.sort((left, right) => right.bytes - left.bytes);
}
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
const responsesByRequestId = new Map<string, string>();
for (const record of records) {
if (record.direction !== "received" || !record.requestId) {
continue;
}
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
}
return records
.filter((record) => record.direction === "sent" && record.requestId && record.type)
.map((record) => ({
requestType: record.type ?? "unknown",
requestId: record.requestId ?? "",
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
}));
}
function summarizeRpcCounts(
records: WireFrameRecord[],
): Array<{ requestType: string; count: number }> {
const counts = new Map<string, number>();
for (const record of records) {
if (record.direction !== "sent" || !record.type || !record.requestId) {
continue;
}
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
}
return [...counts.entries()]
.map(([requestType, count]) => ({ requestType, count }))
.sort(
(left, right) =>
right.count - left.count || left.requestType.localeCompare(right.requestType),
);
}
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
}
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
}
function summarizeProviderSnapshots(
records: WireFrameRecord[],
): WireSummary["providerSnapshotTotals"] {
const byProvider = new Map<
string,
{
phase: WirePhase;
provider: string;
frames: number;
bytes: number;
maxModels: number;
maxModes: number;
statuses: Set<string>;
}
>();
for (const record of records) {
if (record.direction !== "received" || !record.providerEntries) {
continue;
}
for (const entry of record.providerEntries) {
const key = `${record.phase}:${entry.provider}`;
const current = byProvider.get(key) ?? {
phase: record.phase,
provider: entry.provider,
frames: 0,
bytes: 0,
maxModels: 0,
maxModes: 0,
statuses: new Set<string>(),
};
current.frames += 1;
current.bytes += entry.bytes;
current.maxModels = Math.max(current.maxModels, entry.modelCount);
current.maxModes = Math.max(current.maxModes, entry.modeCount);
if (entry.status) {
current.statuses.add(entry.status);
}
byProvider.set(key, current);
}
}
return [...byProvider.values()]
.map((entry) => ({
phase: entry.phase,
provider: entry.provider,
frames: entry.frames,
bytes: entry.bytes,
maxModels: entry.maxModels,
maxModes: entry.maxModes,
statuses: [...entry.statuses].sort(),
}))
.sort((left, right) => right.bytes - left.bytes);
}
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
await testInfo.attach("startup-wire-metrics.json", {
body: JSON.stringify(summary, null, 2),
contentType: "application/json",
});
}
test.describe("ad hoc startup wire metrics", () => {
test.skip(
process.env.E2E_WIRE_METRICS !== "1",
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
);
test("measures startup hydration and workspace navigation websocket traffic", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const monitor = new WireMonitor();
await monitor.start(page);
await gotoAppShell(page);
await waitForSidebarHydration(page, 120_000);
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
await page.waitForTimeout(1_000);
const workspaceTestIds = await page
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
.evaluateAll(extractWorkspaceTestIds);
monitor.setPhase("workspace_clicks");
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
for (const testId of workspaceTestIds) {
const row = page.getByTestId(testId);
if (!(await row.isVisible().catch(() => false))) {
continue;
}
const beforeFrames = monitor.records.length;
const beforeBytes = sumBytes(monitor.records);
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await page.waitForTimeout(1_000);
clickedWorkspaces.push({
testId,
frames: monitor.records.length - beforeFrames,
bytes: sumBytes(monitor.records) - beforeBytes,
});
}
const summary = monitor.summarize(clickedWorkspaces);
await attachSummary(testInfo, summary);
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
console.log(JSON.stringify(summary, null, 2));
console.log("PASEO_STARTUP_WIRE_METRICS_END");
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
expect(clickedWorkspaces.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,249 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import { getTabTestIds } from "./helpers/launcher";
import {
installTerminalRenderProbe,
readTerminalRenderProbe,
resetTerminalRenderProbe,
startTerminalFrameSampling,
summarizeTerminalRenderProbe,
} from "./helpers/terminal-probes";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
interface TerminalLayoutMetrics {
visibleSurfaceCount: number;
surfaceHeight: number;
rowCount: number;
rows: number | null;
cols: number | null;
renderedRowsHeight: number;
tabIds: string[];
}
async function readTerminalLayoutMetrics(page: Page): Promise<TerminalLayoutMetrics> {
const tabIds = await getTabTestIds(page);
return page.evaluate((currentTabIds) => {
const visibleSurfaces = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]'),
).filter((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const surface = visibleSurfaces[0] ?? null;
const surfaceRect = surface?.getBoundingClientRect() ?? null;
// xterm.js exposes `.xterm-screen` regardless of renderer (DOM, canvas, WebGL),
// so use it as the canonical "rendered surface" rect rather than `.xterm-rows`
// which is only populated by the DOM renderer.
const xtermScreen = surface?.querySelector<HTMLElement>(".xterm-screen") ?? null;
const xtermScreenRect = xtermScreen?.getBoundingClientRect() ?? null;
const term = (
window as Window & {
__paseoTerminal?: {
rows?: number;
cols?: number;
};
}
).__paseoTerminal;
return {
visibleSurfaceCount: visibleSurfaces.length,
surfaceHeight: surfaceRect?.height ?? 0,
rowCount: typeof term?.rows === "number" ? term.rows : 0,
rows: typeof term?.rows === "number" ? term.rows : null,
cols: typeof term?.cols === "number" ? term.cols : null,
renderedRowsHeight: xtermScreenRect?.height ?? 0,
tabIds: currentTabIds,
};
}, tabIds);
}
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
let lastBufferText = "";
let lastProbe = await readTerminalRenderProbe(page);
try {
await expect
.poll(
async () => {
lastBufferText = await getTerminalBufferText(page);
lastProbe = await readTerminalRenderProbe(page);
return (
lastProbe.altEnterWrites > 0 &&
lastProbe.altExitWrites > 0 &&
lastBufferText.includes(afterAlt)
);
},
{
intervals: [50],
message: `wait for alternate-screen exit and ${afterAlt} output`,
timeout,
},
)
.toBe(true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Timed out waiting for alternate-screen exit: ${message}\n${JSON.stringify(
{
afterAlt,
probe: summarizeTerminalRenderProbe(lastProbe),
bufferTextTail: lastBufferText.slice(-500),
},
null,
2,
)}`,
{ cause: error },
);
}
return lastProbe;
}
test.describe("Terminal alternate-screen transitions", () => {
test.describe.configure({ timeout: 120_000 });
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-alt-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("restores the normal screen after full-screen alternate buffer exit without remounting", async ({
page,
}, testInfo) => {
test.setTimeout(60_000);
await installTerminalRenderProbe(page);
await withTerminalInApp(page, harness, { name: "alternate-screen" }, async () => {
await harness.setupPrompt(page);
const terminal = harness.terminalSurface(page);
const historyReady = `HISTORY_READY_${Date.now()}`;
await terminal.pressSequentially(
`for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`,
{ delay: 0 },
);
function hasHistoryReady(text: string): boolean {
return text.includes(historyReady);
}
await waitForTerminalContent(page, hasHistoryReady, 10_000);
await resetTerminalRenderProbe(page);
await page.waitForTimeout(500);
const settledProbe = await readTerminalRenderProbe(page);
expect(settledProbe.resetWrites, "terminal should be idle before alternate-screen act").toBe(
0,
);
await resetTerminalRenderProbe(page);
const afterAlt = `AFTER_ALT_${Date.now()}`;
await startTerminalFrameSampling(page);
await terminal.pressSequentially(
`printf '\\033[?1049h\\033[2J\\033[HALT_SCREEN_TOP\\n'; sleep 0.25; printf '\\033[?1049l'; echo ${afterAlt}\n`,
{ delay: 0 },
);
const probe = await waitForAlternateScreenExit(page, afterAlt, 10_000);
const probeSummary = summarizeTerminalRenderProbe(probe);
await testInfo.attach("alternate-screen-probe", {
body: JSON.stringify({ summary: probeSummary, probe }, null, 2),
contentType: "application/json",
});
expect(probe.setCount, "terminal instance should not be replaced after attach").toBe(0);
expect(probe.unsetCount, "terminal instance should not be unset after attach").toBe(0);
expect(
probe.altEnterWrites,
"test command should enter the alternate screen",
).toBeGreaterThan(0);
expect(probe.altExitWrites, "test command should exit the alternate screen").toBeGreaterThan(
0,
);
expect(probe.resetWrites, "alternate-screen exit should not replay a snapshot reset").toBe(0);
const finalBufferText = await getTerminalBufferText(page);
expect(finalBufferText).toContain(historyReady);
expect(finalBufferText).toContain(afterAlt);
function isSuspiciousFrame(frame: (typeof probe.frames)[number]): boolean {
return (
frame.text.includes("$") &&
!frame.text.includes(historyReady) &&
!frame.text.includes(afterAlt) &&
frame.nonEmptyRows <= 2 &&
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1
);
}
const suspiciousFrames = probe.frames.filter(isSuspiciousFrame);
expect(
suspiciousFrames,
"normal-screen restore should not flash to a mostly blank prompt-at-top frame",
).toEqual([]);
});
});
test("opening vim in a new terminal fills the terminal surface", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 1280, height: 900 });
await installTerminalRenderProbe(page);
await withTerminalInApp(page, harness, { name: "vim-layout" }, async () => {
await harness.setupPrompt(page);
const terminalSurface = harness.terminalSurface(page).first();
await expect(terminalSurface).toBeVisible({ timeout: 15_000 });
await terminalSurface.click();
const beforeVim = await readTerminalLayoutMetrics(page);
await startTerminalFrameSampling(page, 2_000);
await terminalSurface.pressSequentially("vim -Nu NONE -n\n", { delay: 0 });
await page.waitForTimeout(1_500);
const probe = await readTerminalRenderProbe(page);
const samples: TerminalLayoutMetrics[] = [];
for (let index = 0; index < 12; index += 1) {
samples.push(await readTerminalLayoutMetrics(page));
await page.waitForTimeout(50);
}
await testInfo.attach("reused-terminal-layout-metrics", {
body: JSON.stringify(
{
beforeVim,
probe: summarizeTerminalRenderProbe(probe),
samples,
},
null,
2,
),
contentType: "application/json",
});
const firstSample = samples[0];
const finalSample = samples.at(-1);
expect(firstSample, "expected an initial layout sample").toBeTruthy();
expect(finalSample, "expected a final layout sample").toBeTruthy();
expect(
finalSample?.visibleSurfaceCount ?? 0,
"opening vim should leave exactly one visible terminal surface",
).toBe(1);
expect(
finalSample?.renderedRowsHeight ?? 0,
"vim should render rows that fill most of the terminal surface",
).toBeGreaterThan((finalSample?.surfaceHeight ?? 0) * 0.75);
expect(
finalSample?.rowCount ?? 0,
"vim should leave a substantial number of rendered rows",
).toBeGreaterThan(Math.max(10, Math.floor((beforeVim.rowCount || 0) * 0.75)));
});
});
});

View File

@@ -0,0 +1,394 @@
import { performance } from "node:perf_hooks";
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl";
import {
installTerminalKeystrokeStressProbe,
readTerminalKeystrokeStressReport,
resetTerminalKeystrokeStressProbe,
type LatencyStats,
} from "./helpers/terminal-probes";
import { waitForTerminalContent } from "./helpers/terminal-perf";
const INPUT_TEXT = buildStressText(600);
const BIG_DIFF_BYTES = 256_000;
const SMALL_AGENT_STREAM_UPDATES = 1000;
const STRESS_TIMEOUT_MS = 15_000;
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
interface DaemonEchoReport {
inputTextLength: number;
inputFrameCount: number;
outputEventCount: number;
echoedBytes: number;
sendToOutputMs: LatencyStats;
firstSendAt: number;
lastOutputAt: number;
}
terminalPerfDescribe("Terminal keystroke stress", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-key-stress-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("logs daemon-only and app keystroke echo latency under burst input", async ({
page,
}, testInfo) => {
test.setTimeout(75_000);
const daemonTerminal = await harness.createTerminal({ name: "daemon-keystroke-baseline" });
try {
const daemonReport = await measureDaemonBurstEcho(harness, daemonTerminal, INPUT_TEXT);
await attachJson(testInfo, "daemon-keystroke-baseline", daemonReport);
console.log("[terminal-stress-daemon]", JSON.stringify(daemonReport));
expect(daemonReport.echoedBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
} finally {
await harness.killTerminal(daemonTerminal.id);
}
await installTerminalKeystrokeStressProbe(page);
const appBaselineReport = await measureAppBurstEcho({
page,
harness,
terminalName: "app-keystroke-stress",
});
await attachJson(testInfo, "app-keystroke-stress", appBaselineReport);
console.log("[terminal-stress-app]", JSON.stringify(appBaselineReport));
expect(appBaselineReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
const appObserveNodeBurstReport = await measureAppObservingNodeBurstEcho({
page,
harness,
terminalName: "app-observe-node-burst",
});
await attachJson(testInfo, "app-observe-node-burst", appObserveNodeBurstReport);
console.log(
"[terminal-stress-app-observe-node-burst]",
JSON.stringify(appObserveNodeBurstReport),
);
expect(appObserveNodeBurstReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(
INPUT_TEXT.length,
);
expect(appObserveNodeBurstReport.xtermWriteCount).toBeGreaterThan(0);
const appSmallChunksReport = await measureAppBurstEcho({
page,
harness,
terminalName: "app-keystroke-stress-small-agent-chunks",
agentStreamUpdateCount: SMALL_AGENT_STREAM_UPDATES,
});
await attachJson(testInfo, "app-keystroke-stress-small-agent-chunks", appSmallChunksReport);
console.log("[terminal-stress-app-small-agent-chunks]", JSON.stringify(appSmallChunksReport));
expect(appSmallChunksReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appSmallChunksReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appSmallChunksReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appSmallChunksReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
expect(appSmallChunksReport.agentStreamTextMessageCount).toBeGreaterThanOrEqual(
SMALL_AGENT_STREAM_UPDATES,
);
const appBigDiffReport = await measureAppBurstEcho({
page,
harness,
terminalName: "app-keystroke-stress-big-diff",
bigDiffBytes: BIG_DIFF_BYTES,
});
await attachJson(testInfo, "app-keystroke-stress-big-diff", appBigDiffReport);
console.log("[terminal-stress-app-big-diff]", JSON.stringify(appBigDiffReport));
expect(appBigDiffReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBigDiffReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBigDiffReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBigDiffReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
expect(appBigDiffReport.largeAgentStreamTextMessageCount).toBeGreaterThanOrEqual(1);
expect(appBigDiffReport.largestAgentStreamTextMessageBytes).toBeGreaterThanOrEqual(
BIG_DIFF_BYTES,
);
});
});
async function measureAppBurstEcho(input: {
page: Page;
harness: TerminalE2EHarness;
terminalName: string;
bigDiffBytes?: number;
agentStreamUpdateCount?: number;
}) {
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
try {
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
await input.harness.setupPrompt(input.page);
const agent =
input.bigDiffBytes === undefined && input.agentStreamUpdateCount === undefined
? null
: await input.harness.client.createAgent({
provider: "mock",
cwd: input.harness.tempRepo.path,
title: "Large WebSocket payload",
modeId: "load-test",
});
const bigDiffBytes = input.bigDiffBytes;
const agentStreamUpdateCount = input.agentStreamUpdateCount;
const startAgentLoad =
agent === null
? null
: () => {
if (bigDiffBytes !== undefined) {
return emitLargeDiffAgentPayload(input.harness, {
agentId: agent.id,
bytes: bigDiffBytes,
});
}
if (agentStreamUpdateCount !== undefined) {
return emitRapidAgentStreamUpdates(input.harness, {
agentId: agent.id,
count: agentStreamUpdateCount,
});
}
return Promise.resolve();
};
const terminal = input.harness.terminalSurface(input.page);
await terminal.press("Control+c");
await input.page.waitForTimeout(200);
await resetTerminalKeystrokeStressProbe(input.page);
const activeAgentLoadPromise = startAgentLoad === null ? Promise.resolve() : startAgentLoad();
await terminal.pressSequentially(INPUT_TEXT, { delay: 0 });
await waitForAppStressEcho(input.page, INPUT_TEXT);
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
if (input.bigDiffBytes !== undefined) {
await waitForLargeAgentStreamMessage(input.page, input.bigDiffBytes);
}
if (input.agentStreamUpdateCount !== undefined) {
await waitForAgentStreamMessages(input.page, input.agentStreamUpdateCount);
}
await activeAgentLoadPromise;
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
} finally {
await input.harness.killTerminal(appTerminal.id);
}
}
async function measureAppObservingNodeBurstEcho(input: {
page: Page;
harness: TerminalE2EHarness;
terminalName: string;
}) {
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
try {
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
await input.harness.setupPrompt(input.page);
await resetTerminalKeystrokeStressProbe(input.page);
for (const char of INPUT_TEXT) {
input.harness.client.sendTerminalInput(appTerminal.id, {
type: "input",
data: char,
});
}
await waitForAppStressEcho(input.page, INPUT_TEXT);
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
} finally {
await input.harness.killTerminal(appTerminal.id);
}
}
async function emitRapidAgentStreamUpdates(
harness: TerminalE2EHarness,
input: { agentId: string; count: number },
): Promise<void> {
await harness.client.sendAgentMessage(input.agentId, `emit ${input.count} agent stream updates`);
}
async function emitLargeDiffAgentPayload(
harness: TerminalE2EHarness,
input: { agentId: string; bytes: number },
): Promise<void> {
await harness.client.sendAgentMessage(
input.agentId,
`emit ${input.bytes} byte large diff agent stream update`,
);
}
async function measureDaemonBurstEcho(
harness: TerminalE2EHarness,
terminal: TerminalInstance,
inputText: string,
): Promise<DaemonEchoReport> {
await harness.client.subscribeTerminal(terminal.id);
const outputTimesByByte: number[] = [];
let outputEventCount = 0;
let echoedBytes = 0;
const decoder = new TextDecoder();
const unsubscribe = harness.client.onTerminalStreamEvent((event) => {
if (event.terminalId !== terminal.id || event.type !== "output" || !event.data) {
return;
}
outputEventCount += 1;
const text = decoder.decode(event.data);
const now = performance.now();
const previousEchoedBytes = echoedBytes;
echoedBytes += text.length;
for (let index = previousEchoedBytes; index < echoedBytes; index += 1) {
outputTimesByByte[index] = now;
}
});
try {
await new Promise((resolve) => setTimeout(resolve, 250));
echoedBytes = 0;
outputTimesByByte.length = 0;
const sendTimes: number[] = [];
for (const char of inputText) {
sendTimes.push(performance.now());
harness.client.sendTerminalInput(terminal.id, {
type: "input",
data: char,
});
}
await waitForDaemonEchoBytes({
getEchoedBytes: () => echoedBytes,
expectedBytes: inputText.length,
timeoutMs: STRESS_TIMEOUT_MS,
});
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
return {
inputTextLength: inputText.length,
inputFrameCount: sendTimes.length,
outputEventCount,
echoedBytes,
sendToOutputMs: summarizeLatency(latencies),
firstSendAt: sendTimes[0] ?? 0,
lastOutputAt: outputTimesByByte[inputText.length - 1] ?? 0,
};
} finally {
unsubscribe();
}
}
async function waitForDaemonEchoBytes(input: {
getEchoedBytes: () => number;
expectedBytes: number;
timeoutMs: number;
}): Promise<void> {
const deadline = Date.now() + input.timeoutMs;
while (Date.now() < deadline) {
if (input.getEchoedBytes() >= input.expectedBytes) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(
`Timed out waiting for daemon echo bytes: ${input.getEchoedBytes()}/${input.expectedBytes}`,
);
}
async function waitForAppStressEcho(page: Page, text: string): Promise<void> {
const tail = text.slice(-80);
await waitForTerminalContent(page, (content) => content.includes(tail), STRESS_TIMEOUT_MS);
}
async function waitForAppProbePayload(page: Page, expectedBytes: number): Promise<void> {
const deadline = Date.now() + STRESS_TIMEOUT_MS;
while (Date.now() < deadline) {
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
if (report.outputFramePayloadBytes >= expectedBytes) {
return;
}
await page.waitForTimeout(25);
}
}
async function waitForLargeAgentStreamMessage(page: Page, expectedBytes: number): Promise<void> {
const deadline = Date.now() + STRESS_TIMEOUT_MS;
while (Date.now() < deadline) {
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
if (report.largestAgentStreamTextMessageBytes >= expectedBytes) {
return;
}
await page.waitForTimeout(25);
}
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
throw new Error(
`Timed out waiting for large agent_stream message: largest=${report.largestAgentStreamTextMessageBytes}, expected=${expectedBytes}`,
);
}
async function waitForAgentStreamMessages(page: Page, expectedCount: number): Promise<void> {
const deadline = Date.now() + STRESS_TIMEOUT_MS;
while (Date.now() < deadline) {
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
if (report.agentStreamTextMessageCount >= expectedCount) {
return;
}
await page.waitForTimeout(25);
}
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
throw new Error(
`Timed out waiting for agent_stream messages: count=${report.agentStreamTextMessageCount}, expected=${expectedCount}`,
);
}
async function attachJson(testInfo: TestInfo, name: string, value: unknown): Promise<void> {
await testInfo.attach(name, {
body: JSON.stringify(value, null, 2),
contentType: "application/json",
});
}
function buildStressText(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let output = "";
while (output.length < length) {
output += alphabet;
}
return output.slice(0, length);
}
function summarizeLatency(values: number[]): LatencyStats {
const sorted = [...values].sort((a, b) => a - b);
const percentile = (p: number) => {
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0;
};
const total = values.reduce((sum, value) => sum + value, 0);
return {
count: values.length,
minMs: round2(sorted[0] ?? 0),
p50Ms: round2(percentile(50)),
p95Ms: round2(percentile(95)),
maxMs: round2(sorted[sorted.length - 1] ?? 0),
avgMs: round2(total / values.length),
};
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}

View File

@@ -16,8 +16,10 @@ const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
test.describe("Terminal wire performance", () => {
terminalPerfDescribe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;

View File

@@ -1,5 +1,6 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { WebSocketRoute } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveAgentFromDaemon,
@@ -27,9 +28,213 @@ import {
waitForSidebarHydration,
} from "./helpers/workspace-ui";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function expectNoLoadingWorkspacePane(
page: Page,
input: { label: string; durationMs?: number },
): Promise<void> {
const durationMs = input.durationMs ?? 2000;
const startedAt = Date.now();
const samples: string[] = [];
while (Date.now() - startedAt < durationMs) {
const url = page.url();
const text = await page
.locator("body")
.innerText({ timeout: 250 })
.catch((error) => `[body unavailable: ${error instanceof Error ? error.message : error}]`);
samples.push(`${Date.now() - startedAt}ms ${url}\n${text.slice(0, 1000)}`);
if (LOADING_WORKSPACE_TEXT_PATTERN.test(text)) {
throw new Error(
`${input.label}: loading workspace pane appeared during reconnect window.\n\n${samples.join(
"\n\n---\n\n",
)}`,
);
}
await page.waitForTimeout(100);
}
}
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
return;
}
activeSockets.add(ws);
const server = ws.connectToServer();
ws.onMessage((message) => {
if (!acceptingConnections) {
return;
}
try {
server.send(message);
} catch {
activeSockets.delete(ws);
}
});
server.onMessage((message) => {
if (!acceptingConnections) {
return;
}
try {
ws.send(message);
} catch {
activeSockets.delete(ws);
}
});
});
return {
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
await Promise.all(
sockets.map((ws) =>
ws
.close({ code: 1008, reason: "Dropped by workspace reconnect regression test." })
.catch(() => undefined),
),
);
},
restore(): void {
acceptingConnections = true;
},
};
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
const workspaceClient = await connectNewWorkspaceDaemonClient();
const archiveClient = await connectArchiveTabDaemonClient();
const workspaceIds = new Set<string>();
const agentIds: string[] = [];
const repo = await createTempGitRepo("workspace-reconnect-");
try {
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
workspaceIds.add(workspace.workspaceId);
const agent = await createIdleAgent(archiveClient, {
cwd: repo.path,
title: `workspace-reconnect-${Date.now()}`,
});
agentIds.push(agent.id);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
await expectWorkspaceTabVisible(page, agent.id);
await daemonGate.drop();
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
timeout: 30_000,
});
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
label: "host reconnect",
});
daemonGate.restore();
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
timeout: 30_000,
});
await monitorReconnect;
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
} finally {
daemonGate.restore();
for (const agentId of agentIds) {
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
}
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await archiveClient.close().catch(() => undefined);
await workspaceClient.close().catch(() => undefined);
await repo.cleanup();
}
});
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), async (ws) => {
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
});
await page.goto(
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
);
await expect(
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("menu-button")).toBeVisible();
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible({ timeout: 30_000 });
await settingsButton.click();
await expect(page).toHaveURL(/\/settings\/general$/);
});
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page,
}) => {
@@ -96,6 +301,14 @@ test.describe("Workspace navigation regression", () => {
`workspace-tab-agent_${firstAgent.id}`,
]);
const firstDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
);
const secondDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
);
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await switchWorkspaceViaSidebar({
page,
serverId,
@@ -126,27 +339,74 @@ test.describe("Workspace navigation regression", () => {
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`,
]);
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.evaluate(
({ agentId, serverId: targetServerId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId: targetServerId,
agentId,
reason: "finished",
},
},
cancelable: true,
}),
);
},
{ agentId: secondAgent.id, serverId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
timeout: 30_000,
});
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expectWorkspaceTabVisible(page, secondAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000,
});
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(secondDeckEntry).toBeAttached();
await expect(secondDeckEntry).toBeHidden();
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: secondWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
title: firstWorkspace.workspaceName,
subtitle: firstWorkspace.projectDisplayName,
});
await expectWorkspaceTabVisible(page, secondAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expectWorkspaceTabVisible(page, firstAgent.id);
await expectWorkspaceTabHidden(page, secondAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`,
`workspace-tab-agent_${firstAgent.id}`,
]);
} finally {
for (const agentId of agentIds) {

View File

@@ -1,7 +1,6 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { waitForTerminalContent } from "./helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
@@ -19,6 +18,18 @@ function getServerId(): string {
return serverId;
}
interface WorkspaceScriptStarter {
startWorkspaceScript(
workspaceId: string,
scriptName: string,
): Promise<{
workspaceId: string;
scriptName: string;
terminalId: string | null;
error: string | null;
}>;
}
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
@@ -94,7 +105,7 @@ test.describe("Workspace setup streaming", () => {
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
@@ -226,7 +237,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("launches script terminals after setup completes", async ({ page }) => {
test("launches script terminals from the workspace scripts menu", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-svc-ui-", {
@@ -263,12 +274,15 @@ test.describe("Workspace setup streaming", () => {
await waitForWorkspaceTabsVisible(page);
// Wait for the script terminal tab to appear in the tabs bar.
// The tab title shows the command, not the script name.
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
await page.getByTestId("workspace-scripts-button").click();
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("workspace-scripts-start-web").click();
await page.getByTestId("workspace-scripts-menu-backdrop").click();
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
// Click the script terminal tab
await terminalTab.click();
// Verify the terminal surface rendered
@@ -285,16 +299,14 @@ test.describe("Workspace setup streaming", () => {
await terminalSurface.click();
// Verify the terminal output contains "listening on" via xterm buffer API.
// The .xterm-rows CSS selector is fragile; reading the buffer directly is reliable.
await waitForTerminalContent(page, (text) => text.includes("listening on"), 60_000);
await expect(terminalTab).toContainText("web");
} finally {
await client.close();
await repo.cleanup();
}
});
test("launches workspace scripts after setup completes", async () => {
test("launches workspace scripts through an explicit daemon request", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: {
@@ -303,7 +315,7 @@ test.describe("Workspace setup streaming", () => {
},
scripts: {
editor: {
command: "npm run dev",
command: "node -e \"console.log('editor ready'); setInterval(() => {}, 1000)\"",
},
},
},
@@ -325,13 +337,24 @@ test.describe("Workspace setup streaming", () => {
throw new Error(result.error ?? "Failed to create workspace");
}
const workspaceDir = result.workspace.workspaceDirectory;
const workspaceId = String(result.workspace.id);
await completed;
const scriptClient = client as typeof client & WorkspaceScriptStarter;
const startResult = await scriptClient.startWorkspaceScript(workspaceId, "editor");
expect(startResult).toMatchObject({
workspaceId,
scriptName: "editor",
terminalId: expect.any(String),
error: null,
});
const findEditorTerminal = (terminal: { name: string }) => terminal.name === "editor";
await expect
.poll(async () => {
const terminals = await client.listTerminals(workspaceDir);
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
return terminals.terminals.find(findEditorTerminal) ?? null;
})
.toMatchObject({
id: expect.any(String),

View File

@@ -39,7 +39,8 @@
"ascAppId": "6758887924"
},
"android": {
"releaseStatus": "draft"
"track": "production",
"releaseStatus": "completed"
}
}
}

View File

@@ -0,0 +1 @@
app_identifier("sh.paseo")

View File

@@ -0,0 +1,42 @@
default_platform(:ios)
APP_IDENTIFIER = "sh.paseo"
platform :ios do
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_P8"),
)
build_number = latest_testflight_build_number(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
)
wait_for_build_processing_to_be_complete(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
build_number: build_number.to_s,
)
deliver(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
build_number: build_number.to_s,
submit_for_review: true,
automatic_release: true,
force: true,
skip_binary_upload: true,
skip_screenshots: true,
skip_metadata: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false,
},
)
end
end

1
packages/app/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -0,0 +1,71 @@
# Maestro Flows
This directory contains local mobile UI flows. Keep flows small enough that a
failure screenshot proves the intended behavior, not just that the app launched.
## New Workspace Android Flow
Use these files when debugging or extending workspace creation on Android:
- `test-workspace-create-android-crash.sh` runs the full regression harness.
- `workspace-create-android-crash.yaml` is the full Maestro flow used by the
harness.
- `record-workspace-create-android-focus.sh` records only the focused repro
window after setup.
- `workspace-create-android-ready-sidebar.yaml` stages the app with the Android
sidebar open and a prepared project visible.
- `workspace-create-android-create-focused.yaml` starts from that staged sidebar
and performs the actual workspace creation.
The reusable pieces live in `flows/`:
- `flows/android-dev-client.yaml` handles Expo dev launcher/dev menu screens.
- `flows/connect-direct-if-welcome.yaml` connects to the local daemon only when
the welcome screen is visible.
- `flows/open-prepared-project-sidebar.yaml` waits for the home screen, opens
the compact Android sidebar, and waits for the prepared project.
- `flows/new-workspace-open-from-sidebar.yaml` taps the project row's
new-workspace action and waits for `/new`.
- `flows/new-workspace-select-codex-gpt54.yaml` selects a real provider/model.
- `flows/new-workspace-submit-and-assert-created.yaml` taps `Create` and proves
the app landed on the created workspace.
Compose new workspace scenarios out of these primitives instead of copying the
old full flow. The shell scripts render the top-level flows and every `flows/*.yaml`
file into the same temp directory, so nested `runFlow: flows/...` paths keep
working with `${PASEO_MAESTRO_*}` placeholders.
The flow is intentionally strict. It must:
1. Open a prepared project from the daemon.
2. Tap the project row's new-workspace action.
3. Select an actual provider/model before tapping `Create`.
4. Tap `Create`.
5. Assert the app lands on a workspace header and the draft composer.
6. Assert `New workspace`, `Select a model`, and the Android redbox text are not
visible.
7. For the shell harness, grep logcat for `failed to insert view` and
`specified child already has a parent`.
Do not weaken this flow to only wait for `message-input-root`. That can pass on
the wrong route. The header assertion and the `New workspace` negative assertion
are what prove the redirect actually completed.
The scripts assume a development build with package id `sh.paseo.debug`, an
already-running local daemon on `127.0.0.1:6767`, and a connected Android device
or emulator. They call `adb reverse tcp:6767 tcp:6767`; they do not restart the
daemon.
```bash
bash packages/app/maestro/test-workspace-create-android-crash.sh
bash packages/app/maestro/record-workspace-create-android-focus.sh
```
Optional environment:
```bash
PASEO_MAESTRO_APP_ID=sh.paseo.debug
PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
```

View File

@@ -0,0 +1,41 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Handle Expo dev launcher/dev menu screens when testing Android development
# builds. Release builds no-op through these optional/conditional steps.
- tapOn:
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
optional: true
- runFlow:
when:
visible: "DEVELOPMENT SERVERS"
commands:
# Expo dev launcher text matching is flaky on Android here; tap the first
# development server row by coordinate as the fallback.
- tapOn:
point: "50%,8%"
- waitForAnimationToEnd
- runFlow:
when:
visible: "Continue"
commands:
- tapOn: "Continue"
- tapOn:
text: "Continue"
optional: true
- waitForAnimationToEnd
- runFlow:
when:
visible: "Performance monitor"
commands:
# Close the Expo dev menu if it opens as a bottom sheet.
- tapOn:
point: "89%,9%"

View File

@@ -0,0 +1,30 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Connect to the local daemon only when the app is on the welcome screen.
- runFlow:
when:
visible:
id: "welcome-screen"
commands:
- tapOn:
id: "welcome-direct-connection"
- extendedWaitUntil:
visible:
id: "add-host-modal"
timeout: 10000
- tapOn:
id: "direct-host-input"
- eraseText
- inputText: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
- assertVisible:
id: "direct-host-input"
text: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
- tapOn:
id: "direct-host-submit"

View File

@@ -24,4 +24,3 @@ appId: sh.paseo
commands:
- tapOn:
point: "50%,20%"

View File

@@ -0,0 +1,17 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Start from an open Android sidebar with ${PASEO_MAESTRO_PROJECT_NAME}
# visible. Ends on the New Workspace screen.
- assertVisible:
id: "sidebar-project-list"
- assertVisible: ${PASEO_MAESTRO_PROJECT_NAME}
- tapOn: "Create a new workspace for ${PASEO_MAESTRO_PROJECT_NAME}"
- extendedWaitUntil:
visible: "New workspace"
timeout: 30000

View File

@@ -0,0 +1,36 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Select a real provider/model on the New Workspace screen. Do not skip this
# in redirect tests: without a selected model, Create only shows validation and
# never exercises workspace creation.
- tapOn:
id: "agent-preferences-button"
- extendedWaitUntil:
visible:
id: "agent-preferences-sheet"
timeout: 10000
- tapOn:
id: "combined-model-selector"
- extendedWaitUntil:
visible: "Select model"
timeout: 30000
- tapOn: "Codex"
- extendedWaitUntil:
visible: "GPT-5.4"
timeout: 30000
- tapOn: "GPT-5.4"
- tapOn: "Close"
- extendedWaitUntil:
visible: "GPT-5.4"
timeout: 10000

View File

@@ -0,0 +1,23 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Start on the New Workspace screen after model selection. Taps Create and
# proves the redirect completed into a workspace draft-agent tab.
- tapOn: "Create"
- extendedWaitUntil:
visible:
id: "workspace-header-title"
timeout: 60000
- extendedWaitUntil:
visible:
id: "message-input-root"
timeout: 30000
- assertNotVisible: "New workspace"
- assertNotVisible: "Select a model"
- assertNotVisible: "There was a problem loading the project."
- assertNotVisible: "The specified child already has a parent"

View File

@@ -0,0 +1,22 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Start from the connected home screen and leave the Android sidebar open with
# the prepared project visible.
- extendedWaitUntil:
visible: "What shall we build today?"
timeout: 60000
- tapOn:
id: "menu-button"
- extendedWaitUntil:
visible:
id: "sidebar-project-list"
timeout: 10000
- extendedWaitUntil:
visible: ${PASEO_MAESTRO_PROJECT_NAME}
timeout: 30000

View File

@@ -3,4 +3,3 @@ appId: sh.paseo
- launchApp:
clearState: true
- runFlow: flows/dev-client.yaml

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Records only the Android workspace-creation repro window.
#
# The setup Maestro flow gets the app to the open sidebar with a prepared
# project visible. Recording starts after that, then the focused flow taps the
# new-workspace button, selects a provider/model, taps Create, and asserts the
# app lands on the created workspace rather than remaining on /new.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
SETUP_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-ready-sidebar.yaml"
FOCUS_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-create-focused.yaml"
OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
VIDEO_DIR="/tmp/paseo-maestro-videos"
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
render_flow() {
local source="$1"
local target="$2"
mkdir -p "$(dirname "$target")"
perl -0pe '
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
' "$source" > "$target"
}
render_flow_tree() {
mkdir -p "$OUT_DIR/flows"
render_flow "$SETUP_TEMPLATE" "$SETUP_FLOW"
render_flow "$FOCUS_TEMPLATE" "$FOCUS_FLOW"
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
done
}
require_command adb
require_command git
require_command maestro
require_command node
require_command perl
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
printf "# Workspace create Android focused recording\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
fi
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
SETUP_FLOW="$OUT_DIR/workspace-create-android-ready-sidebar.rendered.yaml"
FOCUS_FLOW="$OUT_DIR/workspace-create-android-create-focused.rendered.yaml"
render_flow_tree
echo "=== Focused Android Workspace Create Recording ==="
echo "Output dir: $OUT_DIR"
echo "Video: $LOCAL_VIDEO"
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
adb reverse tcp:6767 tcp:6767 >/dev/null
echo ""
echo "Opening project in daemon..."
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const repoRoot = process.env.REPO_ROOT;
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
if (!repoRoot || !projectPath || !daemonUrl) {
throw new Error("Missing required environment for daemon project setup.");
}
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
const { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,
clientId: `maestro-workspace-create-focus-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
const payload = await client.openProject(projectPath);
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "openProject returned no workspace");
}
console.log(
JSON.stringify({
workspaceId: payload.workspace.id,
projectDisplayName: payload.workspace.projectDisplayName,
}),
);
} finally {
await client.close().catch(() => undefined);
}
NODE
echo ""
echo "Staging app at open sidebar..."
(cd "$OUT_DIR" && maestro test "$SETUP_FLOW") 2>&1 | tee "$OUT_DIR/setup.log"
echo ""
echo "Recording focused create flow..."
adb shell rm -f "$DEVICE_VIDEO" >/dev/null 2>&1 || true
adb shell screenrecord --time-limit 90 "$DEVICE_VIDEO" &
SCREENRECORD_PID=$!
sleep 1
set +e
(cd "$OUT_DIR" && maestro test "$FOCUS_FLOW") 2>&1 | tee "$OUT_DIR/focus.log"
FOCUS_STATUS=${PIPESTATUS[0]}
set -e
kill -INT "$SCREENRECORD_PID" >/dev/null 2>&1 || true
wait "$SCREENRECORD_PID" >/dev/null 2>&1 || true
adb shell pkill -INT screenrecord >/dev/null 2>&1 || true
adb pull "$DEVICE_VIDEO" "$LOCAL_VIDEO" >/dev/null
ls -lh "$LOCAL_VIDEO"
if [ "$FOCUS_STATUS" -ne 0 ]; then
echo "Focused Maestro flow failed. Artifacts: $OUT_DIR" >&2
exit "$FOCUS_STATUS"
fi
echo "Focused recording complete."
echo "Artifacts: $OUT_DIR"

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# Android Maestro harness for the workspace-creation redirect crash.
#
# Starts from a clean app state, connects the Android app to the local daemon,
# opens a prepared git project, creates a workspace through the UI, and captures
# adb logcat around the redirect window.
#
# This harness is deliberately stronger than "composer is visible": it selects
# a model, taps Create, asserts the workspace header, asserts the New Workspace
# route is gone, and fails if logcat contains the Android Fabric view-parent
# crash signature.
#
# Usage:
# bash packages/app/maestro/test-workspace-create-android-crash.sh
#
# Optional environment:
# PASEO_MAESTRO_APP_ID=sh.paseo.debug
# PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
# PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
# PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
require_command adb
require_command git
require_command maestro
require_command node
require_command perl
render_flow() {
local source="$1"
local target="$2"
mkdir -p "$(dirname "$target")"
perl -0pe '
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
' "$source" > "$target"
}
render_flow_tree() {
mkdir -p "$OUT_DIR/flows"
render_flow "$FLOW_TEMPLATE" "$FLOW"
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
done
}
if [ ! -f "$SERVER_EXPORTS" ]; then
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
echo "Run: npm run build --workspace=@getpaseo/server" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
printf "# Workspace create Android repro\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
else
PROJECT_PARENT=""
fi
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
echo "=== Workspace Create Android Crash Harness ==="
echo "Output dir: $OUT_DIR"
echo "App id: $PASEO_MAESTRO_APP_ID"
echo "Android direct endpoint: $PASEO_MAESTRO_DIRECT_ENDPOINT"
echo "Daemon websocket: $PASEO_MAESTRO_DAEMON_WS_URL"
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
FLOW="$OUT_DIR/workspace-create-android-crash.rendered.yaml"
render_flow_tree
echo "Rendered flow: $FLOW"
echo ""
echo "Preparing Android port reverse..."
adb reverse tcp:6767 tcp:6767 >/dev/null
echo ""
echo "Opening project in daemon..."
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const repoRoot = process.env.REPO_ROOT;
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
if (!repoRoot || !projectPath || !daemonUrl) {
throw new Error("Missing required environment for daemon project setup.");
}
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
const { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,
clientId: `maestro-workspace-create-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
const payload = await client.openProject(projectPath);
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "openProject returned no workspace");
}
console.log(
JSON.stringify({
workspaceId: payload.workspace.id,
projectId: payload.workspace.projectId,
projectDisplayName: payload.workspace.projectDisplayName,
}),
);
} finally {
await client.close().catch(() => undefined);
}
NODE
LOGCAT_PID=""
cleanup() {
if [ -n "$LOGCAT_PID" ]; then
kill "$LOGCAT_PID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
echo ""
echo "Capturing Android logcat..."
adb logcat -c || true
adb logcat -v time > "$OUT_DIR/logcat.txt" &
LOGCAT_PID="$!"
echo "Running Maestro flow..."
set +e
(cd "$OUT_DIR" && maestro test "$FLOW") 2>&1 | tee "$OUT_DIR/maestro.log"
MAESTRO_STATUS=${PIPESTATUS[0]}
set -e
cleanup
LOGCAT_PID=""
if [ "$MAESTRO_STATUS" -ne 0 ]; then
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
echo ""
echo "Maestro failed. Artifacts: $OUT_DIR" >&2
exit "$MAESTRO_STATUS"
fi
if grep -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >/dev/null; then
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
echo ""
echo "Android native view crash signature found in logcat. Artifacts: $OUT_DIR" >&2
grep -n -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >&2 || true
exit 1
fi
echo ""
echo "PASS: workspace creation flow completed without the Android view-parent crash signature."
echo "Artifacts: $OUT_DIR"

View File

@@ -0,0 +1,26 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
# Android regression/reproduction flow for creating a workspace from an existing project.
# The shell wrapper prepares the daemon/project and captures logcat around this flow.
- launchApp:
clearState: true
- runFlow: flows/android-dev-client.yaml
- runFlow: flows/connect-direct-if-welcome.yaml
- extendedWaitUntil:
visible: "What shall we build today?"
timeout: 60000
- takeScreenshot: 01-connected
- runFlow: flows/open-prepared-project-sidebar.yaml
- runFlow: flows/new-workspace-open-from-sidebar.yaml
- takeScreenshot: 02-new-workspace
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
- takeScreenshot: 03-created-workspace

View File

@@ -0,0 +1,8 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
# Focused repro window. Assumes `workspace-create-android-ready-sidebar.yaml`
# already left the sidebar open with the prepared project visible.
- runFlow: flows/new-workspace-open-from-sidebar.yaml
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
- runFlow: flows/new-workspace-submit-and-assert-created.yaml

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