Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 4d08ddba94 fix(app): prevent concurrent draft agent creation
React submit state is render-lagged, so callbacks entering in the same render can all start create requests. Claim the draft synchronously in the shared create-flow store before the first request begins.
2026-08-12 11:45:51 +00:00
Mohamed Boudra 2f6b585a3a fix(desktop): shut down cleanly with workspace terminals (#3268)
Workspace terminal shutdown only signaled an npm wrapper, leaving Electron alive with broken output pipes. Make the signal-handling runner the terminal-owned process and route external Electron termination through the existing quit lifecycle.
2026-08-12 13:00:55 +02:00
2c5c629db8 fix(usage): read Cursor token from modern state.vscdb key via node:sqlite (#2704)
* fix(usage): read Cursor token from modern state.vscdb key via node:sqlite

Settings → Usage showed Cursor as unavailable for a normal Cursor desktop
login. The quota fetcher read the access token from the legacy `cursorAuthStatus`
key, but current Cursor builds store a plain JWT under `cursorAuth/accessToken`
with no such legacy row, so the lookup returned null.

Read the token with built-in node:sqlite instead of shelling out to a `sqlite3`
CLI (absent by default on Windows) — a second, independent cause of the same
"token not found → unavailable" failure. Prefer the modern key, fall back to the
legacy `cursorAuthStatus` blob for older builds, and log read failures at debug
so an unavailable card stays diagnosable.

Fixes #2586

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

* test(usage): cover Cursor sqlite text and blob auth

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-12 12:25:04 +02:00
Mohamed Boudra 6ed364ffe2 Document per-execution Hub worktree branch names (#3267)
* docs(hub): document per-execution worktree branch names

Hub now materializes `${{ paseo.execution.id }}` inside a daemon
environment's `worktree.newBranch` before it persists or dispatches the
launch, so a reusable branch-off environment can get a stable branch per
execution instead of every trigger contending for one literal name.

The reference states the constraint precisely because it is the part the
YAML cannot show: `newBranch` accepts that one execution-scoped path and
nothing else, and any other expression fails bundle activation with the
authored file and field in the error. Literal branch names stay valid.

* docs(hub): reshape the per-execution worktree branch docs

Split the two pages by the job each reader is doing. The daemon page is a
task guide, so it now carries the worktree YAML and only the two facts
that reader needs to choose a branch name, then links out. The reference
owns the exhaustive contract at the `newBranch` field: a table contrasting
a literal with the template, then the accepted expression scope and what
activation rejects.

Dropped the render-before-dispatch ordering. Readers cannot act on where
Hub materializes the value; the reachable behavior is that a retried or
recovered execution keeps its branch, which is what the table says.

* docs(hub): make the per-execution worktree branch the only guidance

A branch-off Hub environment that aims every execution at one branch name
is not a configuration worth teaching, so the docs no longer present it as
an alternative. Both pages now show `newBranch: trigger-${{ paseo.execution.id }}`
and say what it does, with no literal-branch example and no comparison.

The reference keeps the field's type as a bare syntax fact, along with the
accepted expression, what activation rejects, and the authored field the
failure names.
2026-08-12 18:20:08 +08:00
Mohamed Boudra 74f2158c45 Preserve daemon sessions when workers stall (#3263)
Stop treating heartbeat silence as proof that a live daemon worker should be destroyed. Crash recovery, explicit lifecycle requests, and supervisor-loss cleanup remain unchanged.
2026-08-12 10:58:58 +02:00
Mohamed Boudra d012b6f071 Help agents diagnose Paseo provider problems (#3243)
* feat(skills): add Paseo help and provider diagnostics

Give support agents a topology-aware troubleshooting entry point backed by the deployed documentation index, and expose the daemon provider diagnostic through the CLI so agents can inspect the affected host directly. Agent-launching skills now use configured profiles instead of the removed orchestration preferences.

* test(cli): keep provider diagnostics on real boundaries

Remove the internal client mock and preserve its normalization and host-targeting assertions through the existing isolated-daemon coverage.
2026-08-12 00:57:33 +02:00
Mohamed Boudra 5f5c585e68 fix(app): avoid unsupported Android array sorting (#3241)
Hermes in the production Android app does not provide Array.prototype.toSorted. Multi-host project selection and provider cache eviction could therefore crash when they reached deterministic sorting paths.
2026-08-12 00:23:22 +02:00
Mohamed Boudra 23d7a8eaf9 Improve Markdown file preview readability (#3240)
* feat(app): improve markdown file previews

Keep rendered documents on the chat reading rail, surface YAML front matter as readable metadata, and make shared Markdown links retain the chat accent and hover treatment.

* fix(app): preserve unsafe frontmatter source

* test(app): cover markdown link behavior
2026-08-12 00:18:30 +02:00
Mohamed Boudra 7b167784d7 fix(app): bound Android provider snapshot storage (#3234)
Android AsyncStorage's 6 MiB SQLite ceiling was exhausted by per-workspace provider snapshots with no eviction. Raise the native cap to 10 MiB and bound snapshots to 4 MiB with indexed eviction, clearing legacy unindexed data without scanning payloads on every save.
2026-08-11 22:00:21 +02:00
Mohamed Boudra 40bdb9a043 fix(server): keep daemon workers alive across host sleep (#3235)
The worker watchdog treated wall-clock time spent in host sleep as evidence that the worker was unresponsive. Count consecutive observed heartbeat misses instead so sleep does not destroy live terminal sessions while genuinely stalled workers still restart after the same active observation window.
2026-08-11 21:48:06 +02:00
Mohamed Boudra 2f54c0e309 Use saved Project Settings for new worktree setup (#3233)
* fix(worktrees): honor saved setup configuration

Project Settings writes paseo.json in the source checkout, but existing committed copies in new worktrees previously won silently. Copy the saved project config before lifecycle setup so the command shown to users is the command that runs.

* fix(worktrees): replace config symlinks safely

A selected ref can place a symlink at paseo.json, so copying over that path may overwrite a file outside the worktree. Materialize the saved source config with atomic rename semantics to replace the path entry without following it.
2026-08-11 21:20:30 +02:00
Mohamed Boudra b9d7d4b18e Reduce storage writes during agent streaming (#3232)
* perf(app): skip transient replica cache writes

The replica cache subscribed to the entire session store, so live stream-head updates forced repeated JSON serialization and browser storage writes even though the head is not persisted. Select the cache's persisted inputs once and schedule writes only when that projection changes.

* test(app): move perf cleanup into fixture

Keep the replica-cache performance scenario as a direct setup, stream, measure, and assert flow while the spec-local Playwright fixture owns deterministic mock-agent cleanup.
2026-08-11 19:42:51 +02:00
Mohamed Boudra 1c905a114e Remove unused session message processing (#3231)
* perf(app): remove unused session message state

The legacy message accumulator had no readers but copied its full array for each activity and updated Zustand for every assistant chunk. Keep only the live voice-abort notification and voice transcription behavior.

* test(app): use live session action in task subscription test
2026-08-11 19:28:35 +02:00
163e7d1cc4 fix(omp): negotiate RPC protocol v2 so large model catalogs don't overflow (#3184)
* fix(omp): negotiate RPC protocol v2 so large responses don't overflow

OMP caps protocol-v1 single-line RPC frames at 1 MiB. get_available_models
returns the full effective model catalog (hundreds of models), which exceeds
that cap, so OMP replies with an overflow error ("RPC response exceeded the
transport limit") and the OMP provider fails to start sessions.

Negotiate RPC protocol v2 after OMP's startup `ready` frame when it is
supported: v2 lifts the single-frame ceiling to 64 MiB by chunking oversized
frames. Add rpc_chunk reassembly to the shared JSONL transport so chunked
responses are reconstructed before routing, and gate the negotiation on the
ready frame so it is in place before the first request.

Protocol v2 is negotiated only when the peer advertises support in its ready
frame; peers that stay on v1 (e.g. Pi's --mode rpc) are unaffected.

* fix(omp): make protocol startup atomic

---------

Co-authored-by: pi3123 <pi3123@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-11 19:19:24 +02:00
Hanjun(Tony)andThong Van cdb1163145 Notify callers when watched children close and cap responses (#3192)
Wake caller agents when watched children close and cap embedded child responses at 4,000 characters.

Co-authored-by: Hanjun(Tony) <54878785+wilgon456@users.noreply.github.com>
Co-authored-by: Thong Van <minhthong@gmail.com>
2026-08-11 19:08:41 +02:00
Edi HasajandMohamed Boudra f24664b882 fix: terminalize Codex compaction on turn end (#3211)
* fix: terminalize Codex compaction on turn end

* fix: ignore stale Codex compaction completions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-11 19:02:17 +02:00
Martin HanzíkandClaude Opus 5 a39959219f feat: Implement workspace rename cli (#3209)
* feat(cli): add workspace rename command

`paseo workspace rename <workspace-id> <title>` sets a workspace's
user-visible title through the existing workspace.title.set RPC, and
`--reset` clears the override so the name reverts to the branch or
directory. The command reads the descriptor back so the output shows
what a reset reverted to.

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

* fix(cli): harden workspace rename against read-back and arg failures

Review findings on the rename command:

- A failed workspace list sweep after a successful title change reported
  the whole command as failed. The read-back is display-only, so it now
  runs outside the mutation's error path and degrades to a null name.
- Only --reset needs the descriptor read back; a set title is already the
  resolved display name, so the common path no longer pages the list.
- Dropped the `"code" in error` rethrow. Nothing in that block throws a
  CommandError, but DaemonRpcError always carries a `code` property even
  when it is undefined, so the guard let those through to render as
  UNKNOWN_ERROR with a stack instead of WORKSPACE_RENAME_FAILED.
- Commander 12 allows excess positionals, so an unquoted multi-word title
  renamed to its first word and dropped the rest. The command now rejects
  excess arguments.

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

* refactor(cli): report the rename RPC response instead of reading back

Reading the descriptor back to show what --reset reverted to was the only
reason rename needed a paged workspace sweep, and that sweep dragged in
everything around it: a failure path that could report an applied rename
as failed, a nullable name, and a bespoke warning sink to tell the two
null cases apart.

Sibling commands don't do this. `workspace archive`, `agent mode`, and
`schedule update` all report what the RPC returned and stop; the one
command that reads back is `agent update`, which has the same "applied
but reported as failed" bug. So rename now mirrors `workspace archive`.

`--reset` reports `title: null` rather than naming what it reverted to;
`paseo workspace ls` shows the resolved name. This also reverts the
`collectWorkspaces` extraction, whose only second caller was rename.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:36:58 +00:00
Mohamed Boudra db6520bf36 Show live task progress while agents work (#3227)
* feat(agents): surface live task progress

Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows.

* fix(agents): admit tasks through stream sequencing

* test(agents): expect normalized task status
2026-08-11 18:23:30 +02:00
Mohamed Boudra b2ff359b60 Always create a fresh worktree (#3224)
* fix(server): always create requested worktrees

A slug is a preferred name, not workspace identity. Let the worktree creator suffix occupied paths and branches so duplicate and detached-slug requests still create fresh worktrees.

* fix(server): suffix occupied checkout branches

Fresh creation also applies when checkout targets are already in use. Create a suffixed branch from the requested branch instead of rejecting the request.

* test(server): align occupied branch coverage

* test(server): remove stale worktree reuse expectations
2026-08-11 17:54:59 +02:00
Mohamed Boudra 308e9af845 Add reusable agent profiles (#3208)
* Search models across all providers from the picker root

* Store agent profiles in daemon config and apply agent config in one RPC

An agent profile is a named bundle of provider, model, mode, thinking
option, feature values and notes, stored host-wide next to terminal
profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is
creation-only, so a profile holding one would apply when starting an agent
and silently do nothing when applied to a running one.

Applying a profile through the four single-field RPCs can fail halfway and
leave an agent matching no profile, so agent.config.apply takes the whole
bundle. Model runs first because a thinking option only means anything
against the model that offers it.

Old daemons parse their persisted config strictly and would drop an
unknown agentProfiles key, making a save appear to succeed and vanish, so
both capabilities are advertised on server_info for the client to gate on.

list_profiles exposes the profiles to orchestrating agents, which read the
notes to pick one and copy its values into create_agent.

* Add the agent profiles settings section

Profiles live behind one module boundary so removing the feature is a
directory plus one mount, which is what favourites never was. The picker
and composer will ask for rows and an apply function rather than learning
what a profile contains.

The edit form follows the form-model pattern instead of the terminal
profile modal's per-field state, because provider selection cascades into
which models, modes, thinking levels and features exist below it.

The section is gated on server_info.features.agentProfiles: an older daemon
drops the key on write, and a save that appears to work and then vanishes
is worse than an unavailable state.

* Document agent profiles and name the four senses of profile

The glossary reserved Profile for HostProfile, terminal profiles owned the
user-facing word, and custom-providers.md used it for provider aliases.
Agent profile is now the term, with all four cross-referenced.

data-model.md never documented terminalProfiles either, so both profile
lists are backfilled together along with the whole-list patch semantics and
why absent and empty differ.

* Pin agent profiles to the model picker and delete favourites

Applying a profile writes its values into the agent controls and is then
forgotten, so rows carry no checkmark and no selected state: after applying,
nothing is selected. Chevron still means navigate deeper, so the absence of
one is what marks a row as an action.

A running agent is its provider's process and agent.config.apply carries no
provider, so the live picker only offers profiles matching the agent's
provider rather than showing rows that cannot do what they say. The draft
path has no such limit and switches provider freely.

Draft feature values go through preferences rather than the feature
callback alone, because the draft feature hook clears local values whenever
the provider changes and a provider-switching profile would lose them.

Favourites are deleted rather than migrated: they were device-local
AsyncStorage keyed by provider and model, while profiles are host-wide
daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity
and moves to its real owner; renaming it is its own commit.

* Cover agent profiles and cross-provider search with E2E specs

Model rows expressed selection only as a checkmark glyph, which assistive
tech cannot see and a spec cannot assert. Rows that draw the indicator now
carry aria-selected; profile rows deliberately carry none, because a
profile is an action rather than a selection. That is what makes "nothing
is marked selected after applying a profile" a real assertion instead of a
tautology, with the materialized model one level down as the control.

The search fixture extends claude rather than mock: provider-config.ts
only allows the six shipped providers as extends targets, so a derived
provider cannot inherit the dev-only mock provider.

* State what applying a config bundle does on partial failure

applyBundle is not a transaction and the earlier commit message implied it
was. A step that rejects aborts the rest and the steps that already applied
stay applied, which a profile omitting modelId can reach: provider setters
validate against the agent's live model, so mode can land while a feature
that model does not support is rejected.

Nothing is hidden by that. Every setter emits agent state before returning,
so what applied is streamed to the client next to the rejected response.
Rolling back would re-invoke the setters that just failed, usually against
a session that is gone, and pre-flight validation would restate each
provider's model-dependent checks where they would drift from the setters
that own them.

What sending the bundle in one request removes is the multi-round-trip
failure class: interruption between hops, and interleaving with another
client. The test already pinned this behaviour; its name now says so.

* feat(agent-profiles): refine profile discovery and appearance

Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably.

* fix(agent-profiles): defer profile swatch styles

Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render.
2026-08-11 17:42:35 +02:00
Christoph LeiterandClaude Opus 5 07044136c8 feat(app): switch sidebar Group by from the command center (#3063)
Switching the sidebar between Project and Status grouping takes three
clicks today: open the display preferences menu, open Grouping, pick the
mode. It is the only sidebar preference with no fast path.

Typing "group" in the command center now offers a single entry that
switches to the mode you are not in, so it reads "Group by status" while
grouped by project and the reverse once you are in status mode. With
two modes a "cycle grouping" label would not say where you land, and an
entry that reads the same in both states looks like it does nothing.

The entry is query-only, so the default Cmd-K list is unchanged. It does
not clear the focus restore element, because it navigates nowhere and
focus has to return to whatever you were typing in.

The contribution is built by a pure function in root-contributions.ts so
its branches can be unit tested, mirroring workspace-contributions.ts.
The registration subscribes to groupMode with a narrow selector to avoid
re-registering every root action when host filters are reconciled.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:35:10 +00:00
Christoph LeiterandClaude Sonnet 5 b7ca1f7502 docs: fix typos across contributing, product, and skill docs (#3153)
Fix spelling and grammar errors found in a full sweep of the
repo's English markdown files.

- CONTRIBUTING.md: "contributing to to it", "accpeted", "wil"
- .github/PULL_REQUEST_TEMPLATE.md: "value provider to" instead
  of "value provided to"
- docs/product.md: "vendor-lock in" instead of "vendor lock-in"
- packages/expo-two-way-audio/CONTRIBUTING.md: "prescreptive"
- packages/expo-two-way-audio/README.md: "can bee added"
- skills/paseo-committee/SKILL.md: "considered and reject"

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 13:53:38 +00:00
Mohamed Boudra 917e39495b Show the source branch on development builds (#3216)
Development web and Electron builds now show their source branch in the expanded sidebar, including a branch icon and safe truncation. Dev launchers inject the label at build time; production builds remain unchanged.
2026-08-11 14:18:06 +02:00
Mohamed Boudra 8c003351d0 fix(app): keep theme selection consistent (#3214)
Theme registration, the appearance picker, persistence, and shortcut cycling drifted as new choices were added. Make one ordered catalog own those paths so Auto and Pure Black participate consistently, while lifting the Pure Black selected-workspace surface above its sidebar.
2026-08-11 14:09:44 +02:00
Mohamed Boudra 36a13601dd Let users choose the metadata generation model (#3215)
Expose Automatic and Manual metadata model selection in Host Settings, preserve existing configured fallbacks, and keep structured generation resilient through discovered candidates. Includes Playwright coverage, translations, public documentation, and settings-row alignment polish.
2026-08-11 14:01:00 +02:00
Mohamed Boudra b599d38a77 Make large diffs open quickly and scroll as one surface (#3212)
* perf(app): virtualize large diff rendering

Render bounded diff row groups through one vertical list and one horizontal surface. Bound retained list ranges when expansion grows the data set so large diffs paint promptly without sacrificing scrolling behavior.

* refactor(app): normalize working tree diff options

* fix(app): keep wide diff text reachable
2026-08-11 12:18:48 +02:00
7253228dc5 feat(app): render mermaid diagrams in agent chat with pan/zoom (#2306)
* feat(app): render mermaid diagrams in agent chat with pan/zoom

Fenced mermaid blocks in assistant messages now render as diagrams
instead of code blocks.

Web: mermaid renders inline via dynamic import (code-split, loads on
first diagram). Drag to pan, ctrl/cmd+wheel or trackpad pinch to zoom
about the cursor, double-click to reset; plain wheel still scrolls the
chat. Rendered SVGs are cached across virtualization remounts.

iOS/Android: diagrams render in a WebView running a self-contained
generated bundle (build:mermaid-webview, mirrors the native terminal's
xterm pattern — no CDN, mermaid ships in the app). The inline preview
is touch-shielded and height-driven by a bridge message; tapping opens
a fullscreen viewer where the platform WebView owns pinch-zoom.

Streaming source that doesn't parse yet falls back to the highlighted
code block and keeps the last good render; renders are serialized
through one queue with stale-task skipping so token-by-token updates
cost at most one in-flight layout.

Security: mermaid can fetch external resources during layout (image
shapes, CSS url()) before output sanitization runs — see
mermaid-js/mermaid#7645 — so resource-bearing source is rejected
up front (containsUnsafeMermaidSource) on every platform, htmlLabels
stay off, securityLevel is strict, and the mermaid secure list is
extended so directives can't override app theming or themeCSS.

The lockfile diff includes npm 11.17 normalization churn beyond the
mermaid closure; a no-op npm install produces the same rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): serialize native mermaid renders and pin viewer to last good source

Serialize render messages inside the mermaid webview entry so a newer
streamed update can't re-initialize global mermaid state (e.g. flip the
theme) while a prior layout is still running; superseded queued renders
are skipped. Track the last successfully rendered source on the RN side
and open the fullscreen viewer with that, so tapping mid-stream while
the latest chunk is still invalid can't show a blank viewer.

Addresses PR review feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): pair native mermaid renders with their source via request id

A "rendered" bridge event was credited to the newest sent source, but an
older render can complete while a newer (possibly invalid) chunk's send
is in flight, mis-crediting the older render's success to the newer
source. Render messages now carry a request id that the entry echoes on
rendered/renderError; the RN side resolves the source by id, so the
fullscreen viewer always opens the exact source that produced the
visible render.

Addresses PR review feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): harden mermaid guard and native preview per adversarial review

- Guard bypass (blocker): mermaid accepts quoted and unicode-escaped
  object keys, so quoted "img": evaded the resource denylist while
  still parsing into a real img property. The guard now decodes u/x
  escapes and strips quoting characters before matching, and tests
  both raw and normalized source.
- structuredClone polyfill banner in the webview bundle: the app's iOS
  floor is 15.1 but structuredClone exists only from 15.4; esbuild
  targets don't polyfill runtime APIs.
- Preview height sizes the inner content view so fence padding/borders
  no longer clip the reported SVG height, and measurement happens at
  the final content width (measuring overlay keeps the fence insets).
- The invisible measuring overlay is hit-test transparent, so an
  invalid diagram's fallback code block keeps its copy button and
  gestures.
- renderError now prunes all superseded source-map entries, matching
  the rendered path, so a long broken stream can't accumulate sources.
- The entry's render chain can no longer be poisoned: the whole task
  body is inside try/catch and the chain appends a trailing catch.
- "Mermaid diagram" accessibility label is translated in all locales
  (message.mermaidDiagram) on web and native.
- esbuild pinned in packages/app devDependencies so the generated
  webview artifact doesn't depend on hoisting layout; </script is
  escaped in the generated JS.

Addresses adversarial review round 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(app): mermaid source toggle and visible zoom controls

Web: a hover control cluster on the diagram (zoom in/out anchored to
the viewport center, reset view, view source) mirroring the code
block's hover-copy idiom; always visible on compact form factors where
hover doesn't exist. View source swaps to the highlighted code block
(with its own copy button) and a view-diagram button swaps back.

Native: the fullscreen viewer gains a source toggle beside the close
button, swapping the webview for the highlighted source in a
scrollable view.

All control labels are translated in every locale under
message.mermaid.*; the flat mermaidDiagram key from the previous
commit is folded into the same group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): align mermaid control icons with the app's icon vocabulary

Network is the settings Connections icon — reusing it for "view
diagram" gave one glyph two meanings; use Workflow instead. RotateCw is
the app's established reset/reload glyph (RotateCcw had no precedent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): use Scan for mermaid reset-view control

RotateCw is the app's reload glyph (19 uses) — a reset-zoom button
wearing it invites a reload misread. Scan (corner-bracket fit-to-frame)
is unused and carries the intended fit/reset semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): remove raw NUL byte from mermaid cache key separator

A literal 0x00 in the template string made git classify the file as
binary, rendering the PR diff for it unreviewable. Use the escaped
sequence instead — identical runtime key, plain-text source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): align mermaid source-view toggle with the copy button

The source view wrapped the code block while the fence margins stayed
inside it, so the floating view-diagram toggle hung in the margin
whitespace above the visible box — misaligned with the code block's
copy button and outside its hover region. Hoist the margins onto the
wrapper (both buttons now share the box edge) and render the toggle in
the copy button's plain style so the pair reads as one control row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): close mermaid review findings

* chore(app): finish Mermaid PR rebase cleanup

* refactor(app): isolate diagram rendering and stabilize streaming

* fix(app): keep Mermaid previews stable while streaming

* fix(nix): update npm dependency hash

* fix(app): preserve streamed rows through completion

---------

Co-authored-by: Damon Meledones <dmeledon@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-11 14:46:05 +08:00
Mohamed Boudra 334bf62370 Keep delegated-agent notifications alive through permission prompts (#3177)
* fix(agents): preserve permission notification callbacks

Permission requests consumed the one-shot completion watcher, so callers never heard about later approvals or the final result. Keep the watcher alive across permission checkpoints and include the actionable request payload in each notification.

* fix(agents): notify concurrent permission requests
2026-08-11 00:14:27 +02:00
Mohamed Boudra 6aa9f92156 Stop notifications after removing a host (#3176)
* fix(notifications): stop pushes after host removal

Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable.

* fix(notifications): make token revocation bounded and durable
2026-08-10 23:33:31 +02:00
Mohamed Boudra 29ae25d7aa Show tooltips for all composer controls (#3173)
* fix(app): restore composer control tooltips

Composite composer triggers dropped tooltip interaction props or the anchor ref, so hover either never opened the tooltip or left it off-screen. Preserve pressable props and compose menu refs at the trigger boundaries.

* fix(app): align mode tooltip typography
2026-08-10 21:57:50 +02:00
Mohamed Boudra df9e8e8f06 Use daemon hostnames for initial Hub slugs (#3172)
* fix(hub): send hostname during daemon enrollment

Pass the local hostname as daemon enrollment metadata so Hub can assign useful default slugs without coupling daemon authority to the human CLI login. Document the rollout dependency on Hub accepting the optional field.

* test(hub): verify enrollment hostname payload
2026-08-10 21:55:55 +02:00
Mohamed Boudra 94bda1f925 Remove chat rooms and agent loops before storage migration (#3053)
* refactor(storage): remove chat rooms and agent loops

This is the prerequisite cleanup for the storage backend migration. Legacy wire schemas remain temporarily so mixed-version peers can still parse old messages, while daemon callers now use query-shaped AgentStorage methods that can map directly to SQL later.

* test(cli): remove retired chat and loop coverage
2026-08-10 21:06:47 +02:00
Mohamed Boudra c5d5441c9c Preserve dictation recordings across WebSocket interruptions (#3159)
* fix(app): preserve dictation audio across transport failures

Keep captured PCM in the existing local segment buffer when a WebSocket send fails. Finish only after server acknowledgements and fail an idle drain so the retry path can resend the complete recording.

* fix(app): dispose dictation stream sender cleanly
2026-08-11 02:03:28 +08:00
nikuscsandMohamed Boudra ca2b633286 feat: add file manager context actions (#3027)
* feat(app): add file manager context actions

* fix: reshape file context actions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-10 18:18:23 +02:00
Mohamed Boudra a98d154b6b Search workspace files from the command center (#3059)
* feat(command-center): add workspace file search

Use the existing workspace-aware fuzzy search so file lookup follows the file explorer's ignore and hidden-directory rules. Cmd/Ctrl+P opens a removable Files scope while the normal command center also includes file matches.

* fix(command-center): stabilize file search results

* fix(shortcuts): preserve project picker command

* fix(command-center): keep file results while searching

* feat(command-center): integrate the files scope into the search row

The scope was a 24px pill with 12px text sitting inside the 16px input line, so
it read as a badge dropped into the query rather than the scope of the search.

Make it a leading cell of the search row instead: folder icon, input-sized label,
and a hairline divider before the caret. It stretches to the row's cross size and
bleeds through the header padding with negative margins, so its height is driven
entirely by the input and the panel's own radius clips its corner. Toggling the
scope cannot resize the header, which the new spec pins.

* fix(command-center): restore the files scope as a chip

The previous commit turned the scope into a flush leading cell of the header. Put
the chip back: rounded, bordered, folder icon and 14px label, no divider.

It stretches to the row instead of carrying a height, so it is exactly as tall as
the input's line box and cannot resize the header.

* refactor(shortcuts): regroup and reorder the shortcut help sections

Panels was a junk drawer: the command center, file search, the shortcuts sheet
itself, new terminal, the sidebars, settings, focus mode and theme cycling. The
two shortcuts people reach for most, Cmd+K and Cmd+P, sat inside it below three
other sections. Navigation held both workspace and tab movement.

Sections are now General, Projects & Workspaces, Tabs & Panes, Layout, and Agent
Input. General leads with the command center and file search. Tab movement joins
Tabs & Panes, new terminal joins the tabs it creates, and Layout keeps only the
sidebar and focus toggles.

Row order is now data rather than a side effect of binding-definition order, so
the row someone opens the sheet to find sits at the top of its section. A test
fails when a new shortcut has no place in that order instead of letting it sink
to the bottom unnoticed.

* Keep file search failures in view
2026-08-10 17:02:35 +02:00
Mohamed Boudra 42f1d0b289 docs(hub): align public docs with v0.3 (#3152)
* docs(hub): align public docs with v0.3

Bring the public workflow, API, authority, activity, and self-hosting guidance in line with the released Hub contracts. Clarify the public HTTPS boundary required by provider events while keeping Hub and PostgreSQL private.

* docs(hub): clarify daemon logout scope

* docs(hub): restore Compose path and simplify TLS guidance

* docs(hub): link the single-repo-team-bot example
2026-08-10 16:13:51 +02:00
Mohamed Boudra 48e65b407f Replace Parcel Watcher with Custom Watcher (#3107)
* fix(server): replace wedging file observation

Native watcher teardown could block the daemon main thread during workspace archive. Own recursive observation behind an awaited lifecycle boundary, retain an isolated comparison harness, and gate correctness, resources, and sustained CPU across platforms.

* test(server): accept root watcher evidence

Native recursive watchers may coalesce ignored descendant changes into a root event. Assert that ignored paths stay excluded while a nonignored sentinel proves delivery remains live.

* test(server): preserve watcher comparison evidence

Record Parcel delivery misses without weakening the production observer gate, and allow the Windows stress test to use its declared polling window.

* fix(server): reconcile native watcher omissions

Back macOS and Windows recursive watching with a bounded namespace inventory so coalesced create and delete events converge without unbounded rescans.

* test(server): compare worktree filesystem identity

Windows can return the same worktree through 8.3 and long path spellings. Preserve cardinality and branch checks while comparing the real path.

* perf(server): bound native inventory audits

Reduce full native inventory cadence while preserving a six-second convergence bound, and gate setup readiness and event-loop p99 against platform-appropriate budgets.

* build: refresh Nix npm dependency hash

* perf(server): scope native file reconciliation

* perf(server): defer native safety audits until quiet

* fix(server): move native safety deadline during audits

* perf(server): audit native inventories after settled churn

* fix(server): reconcile native root evidence promptly

* perf(server): deduplicate native audit scheduling

* perf(server): coalesce native event clusters

* perf(server): suppress fresh native change scans

* test(server): separate burst recovery from sustained churn

* test(server): stabilize observer performance gates

* perf(server): skip fresh native change scans

* fix(server): preserve nested native change audits

* perf(server): coalesce native repair scans

* fix(server): reconcile native directory moves promptly

* test(server): bound observer burst repair cost

* refactor(server): own file observer lifecycle

* build: refresh Nix npm dependency hash

* ci: fold observer coverage into server suite

* fix(server): await file observer shutdown
2026-08-10 13:43:48 +02:00
Mohamed Boudra 7954babca6 Publish the TypeScript SDK and integration guides (#3141)
* feat(client): publish the TypeScript SDK surface

Make the high-level client API usable for external integrations and document complete agent, workspace, provider, event, and recipe flows.

* fix(client): correlate provider snapshots by daemon cwd

Return the daemon's canonical snapshot identity so waitForReady can match updates for relative, tilde, and otherwise non-canonical paths without duplicating filesystem rules in the SDK.

* test(server): make snapshot cwd assertion portable

* fix(client): gate canonical provider snapshot waits

Older daemons do not expose the canonical snapshot identity needed to correlate workspace updates safely. Require the advertised capability and fail with an update-host message instead of guessing across workspaces.
2026-08-10 13:22:20 +02:00
Mohamed Boudra 05a0cabc07 Deploy multi-file Hub workflows (#3128)
* feat(hub): deploy canonical workflow bundles

Keep CLI deployment aligned with Hub's authored bundle boundary so validation and activation receive the same deterministic source files. Public documentation now teaches that boundary as the only configuration format.

* test(hub): assert validation diagnostics directly

* refactor(hub): deepen HTTP client module

* feat(hub): validate named agent configurations

* docs(hub): align Claude mode example
2026-08-10 12:32:59 +02:00
b7f137c2a0 Render SVG project icons on native and fall back for ICO (#2579)
* Render SVG project icons on native and fall back for ICO

React Native's <Image> silently decodes neither SVG nor ICO, so any
project whose discovered icon was one of those formats showed a blank
slot on iOS/Android while web/Electron rendered it fine. Route SVG data
URIs through react-native-svg on native and let ICO fall through to the
initial-letter avatar instead of rendering nothing.

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

* Import Buffer explicitly so SVG icons actually decode on device

React Native has no global Buffer and the app installs no global polyfill,
so projectIconSvgXml threw ReferenceError inside its try/catch and returned
null on every SVG — the exact blank-slot bug the previous commit meant to
fix. Vitest runs in Node where Buffer is global, which is why the tests
passed anyway; the new regression test deletes globalThis.Buffer to keep
the import honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Prefer renderable icon formats and unwrap PNG-bearing ICOs

favicon.ico ranked first in ICON_PATTERNS, so any repo with a standard
public/ layout served ICO — which native <Image> cannot decode — even when
an SVG or PNG sat in the same directory. Rank SVG and PNG above ICO for
each name, sniff magic bytes instead of trusting the extension (renamed
PNGs shipped as image/x-icon are common), and extract the largest
PNG-encoded frame from real ICO containers so native clients get a
decodable image instead of falling back to the letter avatar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Render SVG icons with CSS support and fall back on parse failure

SvgXml ignores <style> blocks, which favicon SVGs commonly use, leaving
paths with a default black fill — invisible on dark themes. SvgCss inlines
the styles before rendering. Pass the letter avatar as the fallback so a
malformed SVG degrades to it instead of an empty slot, and fold the
workspace setup dialog's hand-rolled icon cascade into ProjectIconView so
the format handling lives in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Take the platform as an explicit parameter in icon source helpers

The tests previously pinned isNative to true with a module-level vi.mock,
which coupled them to import initialization and left the web pass-through
paths uncovered. Defaulting an explicit `native` parameter to the real
constant keeps call sites unchanged while letting tests exercise both
variants directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(app): isolate native project icon rendering

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-10 12:01:09 +02:00
Mohamed Boudra 02c4bc76a6 Keep Add Project method selection path-free (#3099)
* fix(app): remove search from project method selection

The fixed method list should not accept text because it can be mistaken for the directory path step that follows.

* fix(app): preserve native project picker keys

Keep hardware-keyboard delivery owned by the Add Project flow even when method selection has no visible text field.

* test(app): update add project overlay expectation
2026-08-09 21:46:58 +02:00
Mohamed Boudra ccb4e444ed Rework Hub workflow docs around explicit delivery and GitHub authority (#3086)
* docs(hub): name the Hub tools in workflow prompts

Every Hub workflow example now tells the agent which tool to call:
hub.reply for the user-facing response, hub.finish_execution to
complete the step and record structured output. Granting a capability
through allow_outputs makes the tool available; it does not make an
agent use it, and an agent that is not told answers in its own
transcript and leaves the step to its idle timeout. Explicit
instructions are what worked against real agents.

Classifier and other intermediate steps name hub.finish_execution
alone, so the docs do not imply every step must reply.

Drops the github.reply allow_outputs blocks from the GitHub examples.
The GitHub provider registers no output capabilities, so that
declaration never materializes a tool; those agents comment through
their scoped GH_TOKEN with gh, which the reference and trigger pages
already said. The read-only answer branch has no network authority for
that, so it only finishes.

Also quotes the "Request: ${{ paseo.prompt }}" prompt block in the
reference, which was invalid YAML as written.

* docs(hub): wrap triggering input in its own prompt block

Every Hub YAML example now ends its prompt with the raw triggering
message wrapped in <user-prompt> tags, as a separate block after the
workflow instructions. The agent can see where your instructions stop
and the request starts, which matters most on the steps that read
external Slack, Discord, and GitHub text.

The first workflow explains the convention in two sentences and points
at Hub security for the controls that actually limit an agent. Tags
label the input; they are not containment, and the docs do not claim
otherwise.

Replaces the earlier quoting fix in the prompt-blocks reference: the
message now lives in a block scalar, so the colon needs no quoting.

* docs: base worktree examples on origin/main

Every branch-off example now passes a remote-tracking ref. Paseo
fetches remote refs in the background, so origin/main is current while
the local main is whatever was last pulled, and base resolution tries
refs/heads first: an unqualified main silently branches off stale
history. The gotcha is explained once, next to the first branch-off
example in the worktrees guide; the other pages just use the ref, and
the Hub environment reference links there.

Also points the safety-gate classifier at the structured-output
section, which shows the output wrapper the finish call takes.

* docs(hub): configure Codex read-only through provider options

Hub examples described a read-only Codex step as mode: read-only. That
Paseo mode preset pairs the read-only sandbox with approval_policy
on-request, so an unattended Hub step stops to ask a question nobody is
there to answer. It is also not one of the modes the Codex provider
advertises.

Every read-only step now uses the provider-native passthrough proven by
the real-provider evidence in #3025: approval_policy never,
sandbox_mode read-only, web_search disabled. The security guide already
used this block; the workflow guide, examples, and reference now agree
with it, and the reference says plainly that a Paseo mode ID is a
separate surface from these options.

mode: full-access is unchanged. It maps to approval_policy never, so it
does not stall an unattended step.

* docs(hub): show a classifier choosing the repository and the model

Both routing jobs were only documented as caller-supplied inputs, so
there was no example of the thing people actually want: one read-only
classifier that reads the request, picks the repository it belongs to
and the model it needs, and hands both to a worker. Replaces the
caller-only model example, which the new one covers through its
override values.

The two axes are not symmetric and the example says so. A model is a
value, so values.selected_model goes straight into agent.model. An
environment cannot come from an expression at all: the compiler accepts
a literal name or paseo.inputs.<name> and nothing else, so each
repository needs its own environment and its own conditional step. One
classifier, exactly one worker.

Corrects the routing section, which listed environment among the fields
an agent output may select. It never could.

Also puts a blank line between adjacent steps and environments in the
deadline and repository-routing examples, and bases the daemon worktree
example on origin/main, which the earlier sweep missed.

* fix(website): keep development requests local

Canonical host redirects were applied to LAN-addressed development traffic, including inspector WebSockets. The redirected socket retained the Vite port and caused the development proxy to terminate when the public host timed out.

* docs(hub): order the workflow scenarios from simple to hard

The Hub pages were arranged by the shape of the configuration schema
rather than by what a reader is trying to do, which showed up three
ways: the overview opened on the storage hierarchy, workflow
configurations were split between workflows.md and a separate examples
page, and the examples that existed started with the hardest one.

workflows.md is now a ladder. One Slack trigger answering in a thread
stays inline as the first workflow, and nine further scenarios sit
under it as collapsible blocks, each a complete configuration adding
one idea to the one above: full access, repeated replies, a GitHub
trigger, two ordered steps, a typed input, two triggers, a classifier
choosing a model, a classifier choosing a repository, and last the
override composed with ??. configuration/examples.md is removed and its
configurations live there now.

Two claims in the old material were wrong and are corrected. Nothing
carries between steps except a declared output, so the planning
scenario passes its plan through output.schema instead of telling the
second agent to follow a plan it cannot see, and a worktree environment
creates a worktree per execution rather than sharing one. GH_TOKEN
authenticates the gh CLI but does not configure git, so the pages no
longer imply a step can push without a commit identity and credential
helper on the daemon host.

The overview drops the organization/connections/daemons/projects tree,
which concepts.md already owns, in favour of the smallest configuration
that does something. Hosting is now a link from the product pages
rather than a section in them.

* docs(hub): make GitHub access an explicit step grant

GitHub authority gets its own page. A step holds a token only when it
declares a github block — connection, repositories, permissions,
duration — and Hub supplies GH_TOKEN plus environment-only git
configuration, revoked when the step ends. No credential is ever
implied by the trigger source.

Rewrites the issue-to-pull-request scenario onto the block, repoints
the trigger, reference, FAQ, and concepts pages, and sweeps the Hub
pages for announcing sentences and compressed paragraphs.

* docs(hub): lighten dense reference prose

Restructure paragraph walls into short paragraphs, lists, and code
blocks: logout behavior in daemons, deploy paths and precedence in
configuration, install limits and dispatch fields in the API page,
required outputs and partial resolution in the reference, and the
policy translation and secrets guidance in security. Fixes two
remaining trigger-derived credential claims in triggers/index.

* fix(nix): update npm dependency hash

* docs(hub): document shared prompts and authority defaults
2026-08-09 16:59:00 +02:00
paseo-ai[bot] b0a7d493aa fix: update lockfile signatures and Nix hash [skip ci] 2026-08-09 11:39:14 +00:00
Mohamed Boudra bfec7ac3ad chore(release): cut 0.3.1 2026-08-09 13:30:33 +02:00
Mohamed Boudra 5269c7b592 docs(release): prepare v0.3.1 2026-08-09 13:28:13 +02:00
Mohamed Boudra 05dc2022c8 Keep shared-worktree fetches proportional to ref changes (#3078)
* fix(server): bound shared-worktree fetch refreshes

* fix(server): preserve buffered git metadata events

* fix(server): cover remote ref selection changes

* fix(server): preserve narrow fetch echo routing

* fix(server): preserve ref updates during refresh

Keep normal refresh requeue semantics for watcher events that arrive while a ref-derived calculation is active, so a second same-ref movement cannot be mistaken for the original fetch echo.

* fix(server): narrow external fetch ref updates

Use authoritative fetch boundaries to separate watcher echoes from outside events, and retain remote ref names so packed-to-loose materialization from other Git clients stays narrow without weakening add/delete fallbacks.
2026-08-09 12:51:00 +02:00
Mohamed BoudraandChristoph Leiter c0daf8e066 feat(app): add running status ring and lighten status dots
Keep the animated ring phase-locked across hosts and derive its knockout surface from each host's actual interaction state. Align filled status circles at 6px while preserving the larger needs-input alert.

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-08-09 11:33:58 +02:00
Mohamed Boudra 6ae6231d1f fix(app): prevent inline code from obscuring text
Explicit inline-code line height creates oversized painted backgrounds when mixed markdown wraps on native. Let native font metrics determine the span height so adjacent lines remain visible.
2026-08-09 11:33:58 +02:00
Mohamed Boudra c77a6761af Treat unpublished desktop updates as unavailable (#3076)
* fix(desktop): treat unpublished updates as unavailable

GitHub releases can become visible before their platform updater manifests finish publishing. Treat that specific upstream condition as an unavailable update while preserving Paseo reporting for genuine updater failures.

* refactor(desktop): inject updater runtime dependency

Exercise updater event and rejection behavior through a typed fake at the runtime boundary instead of coordinating a hoisted module singleton in tests.

* fix(desktop): preserve lazy updater initialization

* revert(desktop): drop updater runtime refactor
2026-08-09 11:20:16 +02:00
Mohamed Boudra fd9cc5d693 Restore macOS direct LAN connections (#3071)
* fix(desktop): restore direct LAN connections

Desktop 0.3.0 routed remote WebSockets through the Electron main process, causing previously working macOS LAN connections to fail with EHOSTUNREACH. Revert custom direct headers and restore the renderer WebSocket path used before the regression.

* fix(app): replace host connections by identity

Keep one connection per stable ID when TLS or password settings change. This preserves the current-main upsert invariant while the custom-header rollback removes the unrelated transport feature.
2026-08-09 15:32:31 +08:00
Mohamed Boudra 7761d122d8 Stabilize async server lifecycle tests (#3060)
* test(server): stabilize async lifecycle coverage

* test(server): name teardown dependencies
2026-08-09 14:33:38 +08:00
01a1d3b2b6 Recover daemon workers stalled by workspace Git pressure (#3033)
* fix(server): bound workspace git pipelines

* fix(server): bound workspace git watcher setup

* fix(server): recover unresponsive daemon workers

* fix(server): tolerate transient daemon heartbeat delays

A five-second watchdog threshold can replace a healthy worker during a short event-loop delay. Keep recovery bounded while allowing a 15-second heartbeat gap, and preserve process-level coverage for workers that ignore graceful termination.

* test(server): normalize workspace fixture paths

Workspace Git entry points resolve paths before invoking dependencies. Keep admission-control fixtures keyed by the same canonical paths so their assertions and deferred gates work on Windows.

---------

Co-authored-by: Timi Ajiboye <trypio00@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-09 03:01:34 +02:00
Mohamed Boudra 3bca9b7a92 fix(app): preserve selected list markers when copying (#3055)
Mouse drags start and end inside rendered list items, so the clipboard serializer dropped item semantics even when the visible marker was selected. Treat the marker as explicit selection intent while keeping text-only selections unmarked.
2026-08-09 08:15:45 +08:00
Mohamed Boudra 8ffe7c75d5 Keep the daemon responsive when file watching fails (#3056)
* fix(server): keep watcher failures from stalling the daemon

Native watcher teardown can block the Node main thread when an error overlaps subscription lifecycle work. Reduce lifecycle churn, abandon errored subscriptions into bounded polling, recover only after setup settles, and expose error callback frequency in runtime metrics.

* fix(server): keep watcher recovery bounded
2026-08-09 02:14:00 +02:00
Christoph Leiter 74cf1931b1 fix(shortcuts): show effective bindings in the cheat sheet (#2985)
Render and search the shortcut chord that actually fires, including rebinding default-only display tokens such as question mark and index wildcards. Unassigned rows show no empty badge, and named-key overrides use the shared display vocabulary.
2026-08-09 01:56:32 +02:00
Christoph Leiter 90aa8bbefc feat(shortcuts): allow shortcuts to be unassigned (#2510)
Allow users to clear and restore shortcut bindings, persist the explicit unassigned state, and keep shortcut displays and actions aligned with the binding that fires. Includes focused unit coverage and desktop renderer Playwright coverage.
2026-08-09 01:31:40 +02:00
shrimpandMohamed Boudra c28836b4e5 feat(app): add Pure black theme (#3012)
* feat(app): add AMOLED and all four Catppuccin themes

Add an AMOLED theme (pure black background) and the four official
Catppuccin flavors (Latte light, Frappé/Macchiato/Mocha dark) to the
appearance picker. Generalize the dark-theme builder to support a light
surface variant, register the new themes with Unistyles, and patch all
registered theme keys when applying font/syntax appearance settings.

* fix(app): localize Catppuccin + AMOLED theme labels across language packs

The initial theme commit pasted the English brand/flavor labels into every
locale. Localize them following each pack's convention: transliterate
Catppuccin and the coffee-flavor names into Arabic/Russian scripts, use
katakana/hanzi equivalents in ja/zh-CN, and standard loanwords in
fr/es/pt-BR. AMOLED is a universal acronym and stays as-is except where
the pack transliterates (Arabic).

* fix(app): make Latte terminal ANSI colors readable

* test(app): cover appearance theme picker options

* refactor(app): scope theme PR to AMOLED

* refactor(app): rename AMOLED theme to Pure Black

* fix(app): refine Pure black appearance

* test(app): wait for sidebar toggle icon

* test(app): remove hover timing race

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-09 01:12:53 +02:00
Mohamed Boudra 8f38d04919 Keep the active project when switching workspace hosts (#3051)
* fix(app): preserve projects across host switches

New workspace entry context retained only a host-local placement, so switching hosts could replace the selected project with the destination's first project. Hydrate that placement against canonical project identity while keeping exact clone placement separate.

* fix(app): preserve manual project choice during hydration

Keep the raw route placement key as stable selection context while hydrating the routed project to its canonical identity.
2026-08-09 00:51:07 +02:00
Mohamed Boudra ce7b3a90c0 Give every Hub execution its own workspace lifecycle (#3045)
* fix(server): archive Hub executions by workspace identity

Hub archive previously depended on backing-specific owner state and archived the agent directly, so local and reused workspaces could survive. Resolve the required workspaceId from the execution agent and let the shared workspace archive service own agent, terminal, and backing teardown.

* fix(server): preserve agent archive through workspace teardown

* test(server): keep Hub regression fixtures workspace-scoped

* fix(hub): own executions through fresh workspaces

Hub executions must own a workspace regardless of backing so archival has one lifecycle boundary for agents, terminals, and checkout cleanup. Retain the retired wire selector for parsing compatibility while removing its ownership semantics.

* fix(server): serialize archive metadata writes

* fix(server): preserve archive during reconciliation
2026-08-08 23:55:12 +02:00
Mohamed Boudra cefb71dcac Keep New workspace metadata inside the composer (#3050)
Allow desktop metadata controls to shrink so long host and starting-ref labels truncate without crossing the composer rail. Adds a real-browser regression for the combined long-label case.
2026-08-08 23:29:45 +02:00
49b257c55a fix(app): make session rename reachable in the mobile sessions dropdown (#2924)
* fix(app): make session rename reachable in the mobile sessions dropdown

The per-session actions menu in the mobile tab switcher (a gorhom bottom
sheet) opened as an anchored popover, which on native renders a second RN
Modal stacked on the sheet — the fragile nested-overlay case docs/floating-panels.md
warns about. The actions menu (incl. Rename) never appeared on native, so
sessions could not be renamed from the dropdown.

Give the menu compactMode="sheet" like every other mobile menu in the file,
so it stacks as a gorhom sheet on a gorhom sheet (the supported path). Extract
the accessory into workspace-tab-trailing-accessory.tsx and add a reachability
test that fails if the sheet presentation regresses.

* test(app): cover compact session rename via the tab switcher e2e

Adds a Playwright spec that drives the mobile-viewport sessions dropdown -> per-session actions sheet -> Rename, exercising the real gorhom sheet interaction the fix restores (the desktop rename specs only cover the right-click context menu, and the unit test mocks the menu engine).

---------

Co-authored-by: Klaude <klaude@Klaudes-MacBook-Pro.local>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-08 21:12:16 +00:00
1e0ce69c54 Make the sidebar resize handle usable by touch (#2953)
* fix(app): make the sidebar resize handle usable by touch

The drag handle was a 10px strip at `right: -5`, so half of it sat outside
the sidebar's bounds. Web hit-testing does not clip to the parent box, but
iOS `hitTest:` and Gesture Handler's Android traversal both do, leaving a
~5px target with no visible affordance — hover is the only cue, and hover
never fires on native.

Split the handle by input capability rather than by platform: a fine pointer
keeps the hairline straddling the border, and touch gets a 24x88 grab target
that sits fully inside the sidebar with a permanent grip. The pan gesture now
requires horizontal intent and fails on vertical drift, so a finger on the
grip still scrolls the list behind it.

`useHasFinePointer` replaces the copy of the same media query that lived in
the review surface.

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

* refactor(app): simplify touch resize handle styles

* fix(app): reveal touch resize grip while pressed

Keep the touch grip out of sidebar content while idle, then expose it for the full resize gesture lifecycle so it remains visible during an active drag.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-08 22:32:24 +02:00
Glen Stampoultzis b9b3a24d58 fix(desktop): restore notification sounds (#2582)
Restore desktop notification sounds by default, add a persisted Play sound setting, and group notification permission and testing in a dedicated Notifications section.
2026-08-08 22:28:37 +02:00
Sihyeon JeonandClaude Opus 5 b19db9537c fix(server): summarise Claude AskUserQuestion permission notifications (#2925)
* fix(server): summarise Claude AskUserQuestion permission notifications

Claude's handlePermissionRequest never set title or description on the
AgentPermissionRequest it emits, so buildPermissionDetails fell through to
serialising request.input. For AskUserQuestion that input is the whole question
object, and the push and in-app notifications both showed truncated JSON.

Summarise the first question the way the OMP and Pi providers already do for
their ask_user permissions: title is the question text, description is the
option labels. request.input is untouched, so the permission UI keeps rendering
the full payload.

Closes #2612

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWY2Xdowxr562GXFdxPhjZ

* test(server): assert question permissions through the emitted request

Greptile flagged that the suite imported a production helper that was only
exported for the test, which leaves an unnecessary public export behind and
couples the tests to implementation structure.

The helper is module-private again, and every case now asserts on the
permission request the session emits and the notification body built from it.
Added a case pinning request.input so the permission UI payload stays covered,
and one for a non-question tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWY2Xdowxr562GXFdxPhjZ

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:28:21 +02:00
HunterandClaude Opus 5 1a117652fd Fix Kimi usage refresh, 5 hour session limit, and reasoning levels (#2743)
* Refresh expired Kimi usage tokens

* Test Kimi usage token refresh

* Harden Kimi usage token refresh

Follow-up review of the refresh path added in f6ce001a.

Credential reads no longer depend on the shape of fields this provider
only writes back: declaring expires_at and friends as required numbers
meant a null or stringified value threw away the access token sitting
next to them, turning a working Kimi quota bar into "unavailable" with
no way back. Those fields are nullish and coerced now.

Refreshed tokens are merged into a re-read of the credential file rather
than the copy the request started with, so a write by Kimi Code during
the refresh round-trip survives, and a file deleted in that window stays
deleted instead of being resurrected with fresh credentials. A failed
write logs at warn: Kimi rotates the refresh token, so losing that write
silently logs the user out of Kimi Code.

Only 401 triggers a refresh. A 403 means authenticated but not entitled,
which no refresh fixes, and refreshing anyway rotated the user's refresh
token on every poll.

Also: floor expires_at to whole seconds, indent the file the way Kimi
Code does, derive the temp file name from the real basename, and split
out fetchUsageResponse, which is what puts fetchUsage back under the
complexity limit — the original passed neither lint nor the formatter.

* Keep CLI-rotated Kimi credentials during refresh

The Kimi CLI shares the credential file the quota fetcher writes. A refresh
read the file, merged its new tokens, and renamed over it, so a rotation that
landed while the refresh was in flight was replaced with stale tokens and the
CLI's session broke until credentials were recreated.

Thread the refresh token that was actually spent into the persistence step and
skip the write when the file no longer carries it.

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

* fix(kimi): normalize additional usage windows

* fix(kimi): preserve custom auth while parsing limit windows

* test(kimi): cover additional usage windows

* test(kimi): move usage-window tests into service.test.ts

Every other quota provider is tested in service.test.ts; kimi.test.ts
was the only provider with a standalone collocated test file.

* fix(kimi): resolve reasoning-effort options per model, not per catalog probe

deriveModelDefinitionsFromACP derived thinkingOptions once from whichever
model happened to be active in the one-off catalog probe session, then
copied that same array onto every model. Kimi's ACP adapter only reports
thinking levels for the currently selected model, so K2.7/K2.7-coding-fast
(boolean on/off) and K3 (off/low/medium/high) ended up with identical,
often-wrong options depending on which model the probe defaulted to.

fetchCatalog now reuses the single probe session to switch through each
candidate model and read back its real thought_level options, falling
back to the default options if an individual model's probe fails. Skips
entirely when there's one model or no thinking picker, so providers
without per-model effort levels pay no extra round trips.

* refactor(kimi): merge kimi-usage.ts back into kimi.ts

kimi.ts was the only quota provider split across two files; every
other provider (including claude.ts at 503 lines, larger than the
merged Kimi file) stays in one. Also fixes a nested-ternary lint
error surfaced by the merge.

* fix(kimi): re-validate credentials immediately before the rename

Greptile flagged that saveRefreshedCredentials read the credential file,
checked its refresh token against a stale snapshot, then did async
writeFile work before an unconditional rename — leaving a window where
the Kimi CLI could rotate the file after the check and have its newer
tokens clobbered by our stale merge. The refresh-token-only comparison
also missed a CLI access-token rotation that kept the same refresh token.

Re-read the file immediately before the rename and compare both tokens
against the snapshot the merge was built from, discarding the merge if
anything changed. Narrows the check-then-write gap to the smallest
window possible without cross-process file locking.

* refactor(acp): make per-model catalog probing an injectable resolver

Catalog discovery in ACPAgentClient.fetchCatalog ran sequential
setSessionConfigOption model switches for every multi-model ACP with a
thought_level picker, so a slow or nonconforming agent could stall any
provider's catalog probe under the shared 60s timeout.

Replace the built-in resolvePerModelThinkingOptions with an optional
ACPCatalogModelResolver injected through ACPAgentClientOptions (threaded
through GenericACPAgentClient like the existing transformer/parser deps).
The base client ships no resolver, so catalog discovery derives models
from the initial session response and never switches models. Export the
config-option helpers a resolver needs.

The base regression test pins that a plain ACPAgentClient with multiple
models and a thinking picker never calls setSessionConfigOption.

* feat(kimi): resolve per-model thinking options via KimiACPAgentClient

Kimi exposes different thinking-effort levels per model but only reports
the currently selected model's levels, so the catalog probe must switch
through each model to read them back. Move that logic off the shared ACP
adapter and onto a Kimi-specific client.

Add resolveKimiCatalogModels (the sequential setSessionConfigOption probe,
fallback, and warning) and KimiACPAgentClient, which injects it via the
new catalogModelResolver dependency. Select it for providerId "kimi" in
the registry alongside cursor/kiro/traecli, so only Kimi pays the extra
round trips; every other ACP provider skips model switching entirely.

Tests cover the four probe behaviors on KimiACPAgentClient and the
registry dispatch to it.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:09:26 +02:00
Mohamed Boudra 638693a5d7 fix(app): keep the crash screen readable and its retry reachable (#3043)
The root error fallback was one unbounded scroller, so on a phone the title
rendered under the status bar and a long stack trace pushed Retry off the
bottom of the screen — the one action that recovers the app was unreachable
on exactly the errors that need it most.

The screen is now a fixed shell: a safe-area message block, an independently
scrolling details region, and a Retry that stays put. `SafeAreaProvider` moved
above the boundary in the root tree because the fallback renders when the app
tree below it has already failed, and insets have to be available there.

The hand-rolled Pressable and bordered message box are replaced by the shared
`<Button>` and `<ScrollableCodeSurface>`, which gives the trace a monospace
face and horizontal scrolling instead of wrapping mid-token. On desktop the
trace is capped and the action sits with the message; a button pinned to the
bottom of a tall window reads as detached from the content it belongs to.

Dropped the "Something went wrong" kicker — it restated the title one line
above it.
2026-08-08 20:59:01 +02:00
Christoph LeiterandClaude Sonnet 5 2740c8206f fix(server): resolve exact file paths that git ignores (#3042)
Clicking a file path an agent mentioned failed with "No file found"
whenever git ignored the file, which covers anything written under a
scratch directory such as .dev/.

Backticked paths do not open directly. They resolve through the
directory-suggestions RPC, which the daemon runs with gitignore
filtering on for every workspace search. findExactEntry resolved the
path, confirmed it stayed inside the workspace root, and then discarded
it on the ignore check.

Ignore filtering belongs to discovery. It keeps build output out of the
composer's @-mention picker and out of the directory chooser, and both
of those keep it. It does not belong to retrieval of a path the caller
named exactly, where containment inside the root is the only question
left. That check is now dropped from the exact-path branch alone.

This does not widen what is readable. The file-read path applies no
ignore filtering at all; resolveScopedPath checks only root containment,
before and after resolving symlinks. Every file this surfaces was
already readable through the file explorer.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 20:47:28 +02:00
Jason@HNDandMohamed Boudra 0906d1d7bd fix(codex): omit disabled skills from slash commands (#2759)
* fix(codex): omit disabled skills from slash commands

Codex skills/list returns disabled skills with enabled:false. Filter
them out in loadSkills so Paseo matches Codex CLI/TUI and no longer
surfaces stale slash entries for skills the user disabled.

Fixes #2617

* fix(codex): preserve empty skill discovery

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-08 20:22:48 +02:00
Mohamed Boudra 3d420720c5 docs(hub): document workflow security boundaries (#3032)
* docs(hub): add security guidance for workflows

* docs: register Hub security guide
2026-08-08 17:22:09 +02:00
Mohamed Boudra 375d1d6fb8 Merge branch 'main' of github.com:getpaseo/paseo 2026-08-08 16:59:56 +02:00
Mohamed Boudra f0eb7cea4b feat(providers): add native options and exact MCP grants (#3025)
* feat(providers): add native options and exact MCP grants

Keep provider policy in provider-owned schemas so unattended Hub runs can grant only requested tools from injected MCP servers. Preserve the native resolved sandbox policy when a workflow override is present, including configured writable roots.

* test(hub): verify real provider tool policy

* test(app): avoid pinning transient restart status
2026-08-08 16:49:29 +02:00
Mohamed Boudra b669dad221 Add durable Paseo Hub CLI login (#3022)
* feat(cli): add durable Hub login

Keep organization-scoped human credentials separate from daemon relationship authority. Connect now exchanges explicit or stored CLI authority for a one-time enrollment token, while logout removes human access without implicitly disconnecting automation.

* fix(cli): require TLS for remote Hub credentials

Durable Hub credentials are bearer secrets, so remote origins must not receive them over cleartext HTTP. Keep literal loopback HTTP available for local Hub development.

* fix(cli): make Hub destinations explicit

Resolve optional Hub origins through one shared precedence policy so active logins remain the default context and credentials stay origin-bound. Report the selected destination before human-facing work, keep structured output auditable, and preserve login authority until requested daemon disconnection succeeds.
2026-08-08 15:27:43 +02:00
Mohamed Boudra 7ac4303d8f docs(release): streamline release workflow
Make the release document the single source of truth. ACP drift is explicit opt-in, release preparation uses one remote batch, and a heartbeat owns completion through published artifacts and store submissions. Preserve one changelog entry per beta and collapse the series on promotion.
2026-08-08 13:49:22 +02:00
paseo-ai[bot] e36842b234 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-08 09:04:58 +00:00
Mohamed Boudra 7392e1b767 chore(release): cut 0.3.0 2026-08-08 10:56:30 +02:00
Mohamed Boudra 3196385526 docs: collapse 0.3.0 beta entries into the stable changelog 2026-08-08 10:53:19 +02:00
Mohamed Boudra b167ee02e1 feat(app): search history by workspace, agent, and branch (#2995)
History was a date-grouped list you scrolled. Finding a session from last
week meant eyeballing every row. This adds a search field to the existing
filter rail, next to the host picker.

Search runs on the daemon, not the client. The daemon already holds every
persisted agent in memory, so it ranks the whole history and the client
never has to warn that it only looked at the pages it had loaded.

A searched response is one ranked page with no cursor. The global top K is
always contained in the union of each host's own top K, so merging per-host
pages and slicing back to the limit is the exact global answer — no k-way
merge, and no offset into a ranking that is recomputed per request, which
would duplicate and skip rows as history changes underneath it. Past result
200 of a relevance order the answer is a narrower query, so the footer says
that instead of offering a "Load more" that cannot deliver.

Matching is the app's existing picker matcher, moved to the protocol package
so app and daemon share one implementation instead of drifting into two
"why didn't this show up?" rule sets. Typo tolerance is opt-in, so the
comboboxes keep exact narrowing; the budget is per token, and four-character
tokens get transpositions only, because at that length a free substitution
turns "main" into "mail", "maid" and "rain".

Ranked rows carry the spans they matched on, so results can mark them. The
subsequence and typo tiers match characters the eye would never find
unaided, and an unexplained row reads as a bug. The ranker computes the
spans anyway; sending them keeps the client from deriving a second opinion
that could disagree with the ranking it is explaining.

Hosts that fail or are offline are now named above the list. Without that,
"No sessions match" is a claim the app has no basis for.

Gated on server_info.features.agentHistorySearch, checked once against the
connected hosts. Reachability is reported by the banner instead, so one
offline host in the registry no longer hides the field.
2026-08-08 10:24:51 +02:00
paseo-ai[bot] cc5ce1038d fix: update lockfile signatures and Nix hash [skip ci] 2026-08-07 22:26:31 +00:00
Mohamed Boudra 8d3881f640 docs: clarify Pi 0.84 beta fix 2026-08-08 00:06:24 +02:00
Mohamed Boudra a7c7a6e482 chore(release): cut 0.3.0-beta.4 2026-08-07 23:59:18 +02:00
Mohamed Boudra 082ddb2303 docs: add 0.3.0-beta.4 changelog 2026-08-07 23:51:40 +02:00
Mohamed Boudra dc19b45f23 fix(desktop): keep browser surfaces interactive 2026-08-07 22:34:59 +02:00
dundunHaanddundunHa 98d3281cad fix(pi): stream assistant deltas when pi omits the cumulative message (#2978)
* fix(pi): stream assistant deltas when pi omits the cumulative message

pi 0.84.0 removed the cumulative `message` snapshot from JSON/RPC
`message_update` events, so `handleMessageUpdate` dereferenced
`undefined` and took down the whole daemon on the first assistant delta.
Every prompt to a pi agent crash-looped the worker.

`message_update` carries an `assistantMessageEvent`, which pi only emits
for assistant messages, so a missing snapshot is treated as an assistant
update. `message_start` still carries the message, so the response id
keeps flowing through `activeAssistantMessageId`.

Fixes #2940
Fixes #2946
Fixes #2950

* fix(pi): give the message_update COMPAT tag a version and removal date

Follows docs/protocol-compatibility.md: every shim tag needs a name, a
version, and a removal condition or date so `rg "COMPAT\("` stays a
schedulable cleanup backlog.

---------

Co-authored-by: dundunHa <dundunhade@gmail.com>
2026-08-07 21:26:08 +02:00
Mohamed Boudra 5e197ad0cf style(app): align help menu versions on a right rail
The app and host version lines ran as one sentence, so the versions ragged
with the length of each name and were hard to compare. Menu hints now take an
optional trailing cell and lay out as key/value across the row.

Renaming the string to a plain name drops its interpolation, so every locale
carries the same untranslated product name it already had.

MenuHint renders in one place today (this menu), so the row layout in the
shared component changes nothing else.
2026-08-07 20:28:27 +02:00
Mohamed Boudra 9b5f6f33f6 style(app): indent the sidebar new workspace row
The row only renders under its project header, so stepping it in reads as
part of that project rather than as a peer of it. Extra padding rather than
margin keeps the hover and pressed fills on the same box as every other
sidebar row.
2026-08-07 20:24:46 +02:00
paseo-ai[bot] 3ddebe7221 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-07 16:18:18 +00:00
Mohamed Boudra 86ff0a18c4 chore(release): cut 0.3.0-beta.3 2026-08-07 18:09:43 +02:00
Mohamed Boudra bf38bd728c docs: add 0.3.0-beta.3 changelog 2026-08-07 18:05:52 +02:00
paseo-ai[bot] fcc54621ca fix: update lockfile signatures and Nix hash [skip ci] 2026-08-07 15:39:19 +00:00
Mohamed Boudra f842537142 fix(server): handle deferred Windows PTY failures 2026-08-07 17:32:03 +02:00
Mohamed Boudra c9a9d8fdce Make workspace archive self-healing during setup (#2987)
* fix(server): make workspace archive self-healing

Background setup could keep writing after its workspace record was archived, causing disk cleanup to fail and the initiating client to restore a local ghost. Track setup per daemon so archive can stop it, and let repeated archive requests finish residual cleanup after the record has committed.

* fix(server): await setup process termination

Archive must not proceed from setup cancellation until the process-tree terminator has completed. Join that promise to the streamed command's existing finish path so child close cannot release workspace cleanup early.
2026-08-07 16:47:33 +02:00
Mohamed Boudra 6abfa242d8 fix(app): keep launch selector with mobile controls
Hidden optional rows reserved space in the middle of the compact form. Move those spacers below the visible controls so worktree options push the launch selector down without separating it from the other selectors.
2026-08-07 15:30:57 +02:00
Mohamed Boudra a06e4d60e9 Keep workspace Git refreshes responsive at scale (#2979)
* fix(server): prevent Git watcher fanout stalls

Repository metadata events were refreshing every observed worktree, multiplying Git process submissions and starving workspace create and archive requests behind observation work. Route metadata to the workspaces that depend on it, preserve conservative shared-ref refreshes, and admit interactive lifecycle operations ahead of queued background reads.

* fix(server): preserve Git metadata dependencies
2026-08-07 18:50:43 +08:00
Mohamed Boudra a5184fd08a fix(app): keep browser relay WebSockets protocol-neutral (#2976)
Use the standard browser WebSocket constructor without the Node/React Native options object, while retaining the options-capable factory for supported runtimes and the desktop bridge.
2026-08-07 17:30:43 +08:00
Mohamed Boudra 75a5065bb3 fix(app): apply clipboard syntax at selection boundaries
Selections contained inside formatting copy only their visible content. Preserve Markdown syntax when the range includes or crosses the semantic element, while retaining list and table structure needed to serialize child boundaries.
2026-08-07 10:43:44 +02:00
Mohamed Boudra 84b735fbea fix(app): preserve nested list clipboard lines
Flattened child list lines were reparented inside the parent item line, so rich paste targets split markers from their content. Keep every flattened line as a sibling and cover cross-structure selection plus inline-code double-click copying.
2026-08-07 10:25:43 +02:00
Mohamed Boudra 64058722e1 Deploy Hub configurations from the CLI (#2955)
* feat(cli): add Hub configuration deploy

Deploy configuration bytes directly to an explicitly targeted Hub project using only caller-supplied origin and organization API key authority.

* refactor(cli): name Hub deploy contracts

* docs(hub): complete deploy CLI guidance

* docs(hub): link deployed API references

* feat(cli): bundle referenced Hub partials

Send exact referenced prompt-partial content alongside YAML so Hub can resolve local includes while inline-only configurations keep the existing request shape.

* fix(cli): match Hub YAML size semantics

Apply the finalized Hub request limit to decoded YAML length and cover multibyte configuration payloads without changing the wire body.
2026-08-07 09:40:36 +02:00
Mohamed Boudra 3a6d6db9a5 fix(app): anchor image growth during timeline reload
Intrinsic image dimensions can arrive after the timeline has painted, outside the React segment-change path. Let the browser preserve the visible content anchor during that resize instead of exposing a frame before the deferred bottom correction.
2026-08-07 08:26:39 +02:00
Mohamed Boudra c352632b74 Launch a terminal instead of a chat from New workspace (#2941)
* feat(app): launch a terminal from the New workspace screen

New workspace only ever started a chat agent. Empty workspaces existed but
were reachable only by submitting an empty composer, which nothing advertised,
and terminals could not be started from this screen at all. People concluded
Paseo did not support terminals.

Adds a launch chip to the meta row, shaped from the same ComboboxTrigger as
its neighbours, that chooses between Chat and a terminal. Terminal profiles
mark where a typed prompt goes with a {{{prompt}}} sentinel in their command
or args, substituted client-side before create_terminal_request, so the daemon
learns nothing new and the protocol is untouched. A profile without the
sentinel takes no input: the composer goes read-only and shows the exact
command, so there is nothing to type wrong.

Prompt forms come from each CLI's own --help. claude, codex, and pi take a
trailing positional; opencode's positional is the project directory, so it
uses --prompt=. Profiles predating the sentinel adopt the right form on read,
keyed on the command's base name rather than the profile id, because profiles
created in settings get generated ids.

* fix(app): stop existing launchers passing the prompt sentinel literally

Adoption made every caller of resolveTerminalProfiles see sentinel-bearing
args, but only New workspace substituted them. Pinned targets and the
workspace terminal menu forwarded profile.args straight through, so launching
Claude from an existing workspace ran `claude "{{{prompt}}}"` with that text as
the prompt.

Every launcher now goes through resolveTerminalProfileLaunch. Surfaces with
nowhere to type pass an empty prompt, which drops the prompt-only arg and
gives back the bare command they had before.

Also from the same review:

- Adoption skipped when args lead with a subcommand. `opencode run` takes a
  positional message rather than --prompt, and `codex login` takes no prompt at
  all. Only the first arg is checked, so `pi --thinking high` still adopts.
- Settings edits the persisted list again, not the adopted view. It writes the
  whole list back on any change, so resolving first baked read-time adoption
  into the user's config the moment they reordered a row.
- A hidden draft no longer names a workspace. Text typed for a prompt-taking
  profile stayed in state after switching to a read-only one and still seeded
  the generated title and branch.
- Windows .cmd escaping now has a round-trip test: a real cmd.exe runs a real
  .cmd shim that echoes its argv, covering metacharacters, an injection
  attempt, and newlines. The existing tests only asserted the command line we
  generate.
- Fixed an ENOTEMPTY flake in the terminal suite. kill() only signals, so the
  shell was still writing into $ZDOTDIR while its directory was being removed.
  Cleanup waits for exit instead.

* fix(app): keep the host and isolation accessible names stable

Wiring the new tooltips also replaced these two accessible names with the
tooltip copy, which broke `sidebar-project-grouping` at the host picker: its
selector is `getByRole("button", { name: "Host", exact: true })` and the button
had become "Choose the host". It timed out on both attempts in CI.

An accessible name identifies a control; a tooltip explains it. Conflating them
renamed a control that other surfaces address by name. The names go back to
what they were and the tooltips keep the "Choose the ..." copy.

* test(server): stop temp-dir teardown failing unrelated terminal tests

The ENOTEMPTY flake survived both earlier attempts. Waiting for the session to
exit shrank the window; retrying the remove did not close it either. The writes
come from processes zsh forks on the way out, which outlive the pty and answer
to nothing the test holds.

Teardown is now best-effort and logs what it left behind. A temp directory we
cannot remove is not a defect in the terminal code under test, and failing on it
reports the race against whichever assertion ran last, which is exactly how this
surfaced: as a flake in an unrelated zsh title test. 10 consecutive runs green.

Also replaces a cast in the Windows .cmd helper with a real narrow, so a change
to ResolvedTerminalCommand fails loudly there. Test files are excluded from
typecheck (tsconfig.server.json), so a cast in one is never checked at all.

* fix(app): keep terminal launch policy in its owning workflows

Profile adoption now parses known global option values before deciding whether argv already contains a positional. Workspace naming is derived inside the terminal workflow so hidden launch-only drafts cannot affect branch or title generation.

* fix(server): preserve cmd shim arguments through both parses

npm command shims forward %*, so metacharacters lose one escape layer before the target process receives argv. Exercise the production node-pty string command line against a forwarding shim, and only tolerate the known ENOTEMPTY teardown race.

* fix(server): stabilize Windows subprocess tests

* test(server): isolate Windows argv recorder output

* test(server): allow native watcher integration to settle
2026-08-07 14:08:22 +08:00
Yoonho SonandMohamed Boudra 292a9c4c8c docs: add Korean README translation (#2947)
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-07 01:18:25 +02:00
Christoph LeiterandClaude Opus 5 b4357139bb fix(claude): show the Fast toggle on Opus 5 (#2939)
The Claude model manifest declares fast-mode support per model, and the
`claude-opus-5` entry was added without `supportsFastMode`. Claude Code
supports fast mode on Opus 5, so the composer and Command Center hid a
toggle the model actually has.

Set the flag. Everything downstream already keys off it:
`claudeManifestModelSupportsFastMode` gates `buildClaudeFeatures`, which
feeds `listFeatures`, the session `features` getter, `setFeature`, and
the `setModel` auto-clear.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:14:21 +08:00
Mohamed Boudra ef6d7b3b9a docs(hub): document required output contracts (#2948)
* docs(hub): document required output contracts

* docs(hub): link faq to output contract

* docs(hub): document materializable required outputs

Explain that required declarations resolve through registered capabilities, failed deliveries can be retried, and command-line repository comments use the scoped credential path.
2026-08-06 22:44:25 +02:00
Mohamed Boudra 1e03e2d120 docs(hub): clarify daemon slug references (#2952)
Hub configuration resolves a normalized friendly slug to an immutable daemon ID. Document that model consistently so enrollment terminology matches hub.yml.
2026-08-06 22:00:06 +02:00
Mohamed Boudra 42bfd4e04c Stop terminal activity after interrupted turns (#2942)
* fix(terminal): clear activity when turns are interrupted

Some terminal agents do not emit a stop lifecycle hook after a user interrupt. Treat exact interrupt key frames as cancellation at the terminal input boundary so running state cannot stick, and clear rather than finish to avoid completion attention.

* test(terminal): use platform shell in interruption tests

Let the terminal test fixture select the platform default shell so the interruption cases reach their assertions on Windows.
2026-08-06 16:30:44 +02:00
Mohamed Boudra a9034ec642 fix(app): preserve running tab accessibility
Keep the blue running dot while retaining the progressbar semantics used by assistive technology and browser-level running-state checks.
2026-08-06 16:22:21 +02:00
Mohamed Boudra deb5e5c729 docs(hub): document durable workflow routing (#2943)
* docs(hub): document durable workflow routing

* docs(hub): make workflow guide progressive
2026-08-06 16:07:41 +02:00
Mohamed Boudra bb55e5bc0d fix(app): align needs-input alert sizes
Keep the filled needs-input alert at one shared size across sidebar badges, workspace rows, and tab descriptors.
2026-08-06 15:40:57 +02:00
Mohamed Boudra 9c05de6f12 fix(app): emphasize needs-input status indicators
Use a filled amber alert for needs-input while keeping running and review dots visually consistent between workspace rows and tabs.
2026-08-06 15:36:21 +02:00
Mohamed Boudra f4e8522050 Show workflows in the subagent track (#2933)
* feat(server): show provider workflows in subagent track

Normalize workflow tasks into the provider-subagent contract so shared clients need no workflow awareness. Persisted nonterminal runs replay as failed because their original runtime can no longer deliver a terminal update.

* fix(server): preserve workflow replay and tool cards

* fix(server): suppress workflow notification cards

* fix(server): replay workflow child timelines

* fix(server): fail tasks when Claude exits

* fix(server): preserve workflow replay parity

* fix(server): fail tasks on Claude restart

* fix(server): hide workflow child prompts on replay

* fix: terminalize workflows on session reload

* fix(server): own workflow notification routing

* feat(server): show workflow results in detail pane

* fix(server): preserve unrestored task notifications

* fix(server): normalize replay task notifications
2026-08-06 21:01:26 +08:00
Mohamed Boudra c1f7ed3198 Improve reconnect status feedback (#2931)
* fix(app): debounce reconnect status toast

Keep reconnect transport immediate while delaying the visible status toast so brief outages stay quiet. Scope the toast to the active retained panel, keep its copy and status dot consistent across locales, and add a manual real relay deployment journey that verifies streaming recovery without user action.

* fix(app): isolate manual relay deployment check

Keep the deployment journey out of the provider-wide real test project so it runs only when its local relay prerequisites are explicitly requested. Also keep the relay teardown active when packaged daemon startup or later cleanup fails.

* fix(app): preserve reconnect toast across tabs

Keep reconnect presentation timing at the host level so switching between retained panels does not restart the debounce or flash the toast. Add a browser regression covering a visible outage across retained tab switches.

* fix(app): anchor reconnect debounce to host status

Record host connection status transitions independently of mounted agent panels and preserve the outage start across reconnecting states. Use that host-level timestamp when a visible agent panel appears, with runtime coverage for the transition lifecycle.

* fix(app): reset reconnect toast state per outage

* test(app): build relay journey prerequisites
2026-08-06 13:45:34 +02:00
e99eb7de3d fix(app): keep line breaks and indentation in copied code (#2935)
* fix(app): keep line breaks and indentation in copied code

#2930 strips the pre/code tags from a partly selected code block, which
is right for the Markdown syntax but leaves the code itself going
through Turndown as prose. Turndown collapses whitespace, so two
selected lines arrive as one, the indentation is gone, and
Markdown-significant characters come back escaped.

Divert a partial code selection before that: copy the selected text
verbatim. Fully selected regions are untouched and still produce a
fence, which is what hasSelectedAllContents was already deciding.

The html half needs pre for anything multi-line -- HTML collapses
newlines too, so a rich paste target would lose the lines the same way.
A single line stays undecorated, matching the rule that a partial
selection does not carry its container's formatting.

Two trailing-newline fixes ride along, since both make a paste run code
nobody asked to run. Newlines between rendered lines are their own text
nodes, so a selection that overshoots the end of a line carries one;
strip it. The block's Copy button was handed the raw fence body, which
ends in a newline; hand it the rendered text instead.

* fix(app): strip every trailing blank line from copied code

The Copy button stripped one trailing newline, so a fence whose author
left a blank line before the closing delimiter still put an executable
newline on the clipboard. Pasting that into a terminal runs the last
line, which is the thing the strip existed to prevent.

Both copy paths now share one regex in markup.ts and remove every
trailing break, plus any indentation that followed the last one.

Reported by Greptile on #2935.

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

* fix(app): infer code copy format from selection boundary

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-06 11:27:38 +00:00
Mohamed Boudra 2298e9efdf Fix composer toolbar collapse flicker on tab switch (#2937)
Retained tabs stay mounted under display:none while inactive, which
reports a zero-width onLayout. ControlledAgentControls fed that
straight into its density resolver, so a hidden tab's toolbar
collapsed to its tightest density and painted one frame collapsed
before re-expanding when the tab became visible again.

Extract the zero-width guard that useContainerWidthBelow already had
into a shared readMeasuredWidth helper, and use it in the composer
toolbar's own layout handler so a display:none measurement can never
drive its density.

Also bump the chat outline rail's MIN_PANEL_WIDTH breakpoint (818 ->
918) so the rail disappears earlier under a narrower panel, and widen
the chat-outline e2e spec's viewport so its panel keeps healthy margin
over the new threshold.
2026-08-06 18:37:38 +08:00
Mohamed Boudra f2054cc701 Preserve Claude model and thinking preferences (#2912)
* fix(agents): preserve Claude thinking selections

Model switches now resolve thinking against the target model's saved preference instead of resetting to the catalog default. Claude owns its configured-model defaults and legacy Fable alias handling, while persisted permission modes remain unchanged.

* chore(claude): tag Fable compatibility shim

* fix(app): resolve persisted model aliases

* fix(models): preserve aliases for older clients

* fix(models): hide compatibility aliases from listings

* fix(settings): hide compatibility model aliases

* test(claude): cover legacy catalog entry

* test(app): expect mock thinking controls in schedules

* fix(app): preserve queued thinking preferences

* fix(app): reconcile schedule provider snapshots

* fix(app): preserve schedule model selections

* fix(claude): preserve exact configured model ids

* fix(app): preserve committed thinking preferences

* fix(app): preserve aliased schedule thinking
2026-08-06 11:33:18 +02:00
73ba37a07a Add custom headers to direct connections (#2922)
* Add custom headers to direct connections

* Address custom header review feedback

* Fix direct connection header integration

* Fix desktop relay E2E transport setup

---------

Co-authored-by: Claude <claude@Claudes-Mac-mini.local>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-06 11:05:40 +02:00
Mohamed Boudra 098122be8d Keep copied assistant selections within their visible boundaries (#2930)
* fix(app): respect assistant copy selection boundaries

Selection serialization reintroduced semantic ancestors even when the user selected only their inner text, causing list markers and code delimiters to expand beyond the visible range. Preserve semantics only for complete selected nodes, and keep rich list payloads compatible with text composers that prefer HTML.

* fix(app): preserve loose list copy breaks
2026-08-06 10:52:40 +02:00
BrianAguilarWascoandMohamed Boudra 5465d66edd Fill OpenCode subagent info: task, type, model, and tokens (#2909)
* feat(opencode): fill subagent descriptor with task, type, model, and tokens

OpenCode subagent rows rendered with no information: the initial upsert
carried only the child session title and status, so the track showed a
bare title with an empty second line and the pane tab fell back to
"Subagent".

Fill the same provider-agnostic descriptor fields Claude populates since
#2498, from data OpenCode already emits:

- description <- the task tool call's description input (fallback: the
  child session title), so the task names the row
- title <- subagent_type from the task call, else the session's agent
- toolCallId <- the parent task tool call id
- subtitle <- "agent · model · Variant · Nk tokens", folded from the
  child's own session record and assistant frames, deduped per child

Facts are never inherited from the parent; absent facts render as
nothing. Presentation upserts omit status so a late subtitle update
cannot revert a finished child (the store's sticky contract). Both
task-link orderings are handled, and the hydration path derives the
same facts for historical children so restored sessions match live.

Server-side only; no protocol or app changes.

* fix(opencode): emit subagent type as descriptor title from assistant frames

* feat(app): show subagent info line in the provider subagent pane

* polish: address subagent presentation review nits

* fix(opencode): link subagents from task metadata

Current OpenCode task results identify their child session in tool metadata rather than the legacy textual output. Preserve that boundary field so parallel task calls retain the correct description, type, and tool-call association.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-06 01:09:31 +02:00
Ivan MalisonandClaude Fable 5 e1b4058b77 fix(app): emit transform-origin as x-then-y for native menu popovers (#2911)
CSS accepts transform-origin keyword pairs in either order, but React
Native parses them positionally (x first). The vertical-first string
crashed anchored menu popovers on native wide form factors with
"Transform-origin left can only be used for x-position".

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 00:38:25 +02:00
Nicholas Maglowsch Salgueiro c59b47b8a6 fix(server): report Claude runtime death instead of sitting idle (#2910)
* fix(server): report Claude runtime death instead of sitting idle

When the Claude Code CLI process dies on its own — crash, OOM kill, host
suspend — nothing in the daemon observes it unless a turn happens to be in
flight. The query pump only reports a failure while it is draining a turn, so
an idle agent's stream just ends: the agent stays `idle`, indistinguishable
from a healthy resumable one.

Everything the agent parked inside that process dies with it. Background Bash
shells, `Monitor` watches and workflows all live in the CLI process, so the
completion notification that would have woken the agent back up never arrives.
The work is silently gone and the user finds out much later, if at all.

The stale handles are never dropped either, so writes keep going to a dead
transport for as long as the daemon lives (#2472).

Watch the spawned child at the single seam it is already handed over
(`onChildProcess`). When it exits between turns, drop the dead query/input
handles so the next write spawns a fresh process, and emit `turn_failed` —
the manager already routes a non-foreground `turn_failed` to `error` plus a
timeline entry.

A death mid-turn is left alone. The pump already waits for stderr to flush and
reports the real cause; reporting here first would replace that with a bare
exit code.

Intentional teardown stays quiet. All three sites that retire a process detach
the child handle first: `close()` sets `closed`, and `ensureQuery()` and
`handleMissingResumedConversation()` null `childProcess` before ending stdin.

Fixes #2472

* fix(server): tree-kill the retired process on missing-resume recovery

The missing-resumed-conversation path ends the child's stdin but never
tree-killed it, so MCP children of the retired claude process could outlive
it as orphans. Every other teardown path already does this; do it here too.

Reported by Greptile on #2910.
2026-08-06 00:36:18 +02:00
Mohamed Boudra 212cd00405 test(app): make chat outline transit timing deterministic 2026-08-06 00:13:17 +02:00
Mohamed Boudra 254e72ad50 Merge remote-tracking branch 'origin/main' 2026-08-06 00:12:30 +02:00
Mohamed Boudra eb95a6eea6 Keep desktop browser tabs stable across focus and automation (#2907)
* fix(desktop): preserve browser tabs across focus changes

Electron can replace a guest WebContents when a webview is reparented, which reloads the page and can race automation against a destroyed target. Keep each browser guest on one permanent paintable surface and make user and agent resizing share one canonical viewport.

* fix(desktop): recover browser surfaces after lifecycle changes

* fix(desktop): preserve resident browser lifecycle state

* fix(desktop): seed inactive browser viewport
2026-08-06 00:05:21 +02:00
Mohamed Boudra 9f84522521 refactor(app): make the sidebar's running status a plain dot
The running dot no longer animates, and the machinery behind it is gone
rather than switched off: the shared refcounted clock, the theme-scheme prop
mapping, and the extra wave color all existed only to drive it.

Four affordances were tried and each failed for its own reason, which is
worth writing down before someone reaches for one of them again. Fading the
dot blends it toward the row's surface rather than toward black, so the
bottom of every cycle was a washed-out blue that read as the success dot one
row up. Dropping chroma holds brightness but turns the low point grey.
Raising chroma has nowhere to go: blue at these lightnesses is already within
11% of the sRGB gamut edge, so the only real headroom is at the hue-250 cusp,
which costs lightness. An expanding ring solved the color problem outright —
motion cannot be confused with another hue — but stayed too subtle at a size
that suits an 8pt dot sitting beside a title.

If the ring is revisited: the project badge shell clips its contents to 12pt,
which is what made the first attempt invisible on exactly the rows most
likely to be on screen. The clip is not what makes the shell read as a
knockout; its fill is.
2026-08-05 23:51:06 +02:00
7f7e8923eb fix(server): stop Claude replay from accumulating running subagents (#2876)
* fix(server): finish subagents the parent transcript can never end

A skill-spawned subagent — a /code-review run, for example — has no Task
tool_use for the parent to record, so its sidecar carries no toolUseId
and its id appears nowhere in the parent transcript. Replay resolved no
link, emitted no status observation, and the store defaulted the
descriptor to running. The subagents track then reported a running child
on every reopen, days after it had finished, and the archive dialog
offered to stop it.

Paseo only records an outcome for a child whose Task call it parsed out
of the parent, so a child with no Task call is one no reader can finish.
Resolve identity and status together and mark those completed. A child
that does have a Task call but no result yet is the opposite case — the
parent was interrupted mid-Task — and keeps running.

Keying on "no Task call for this id" rather than "no toolUseId" also
covers legacy grandchildren. Across 554 recorded subagent transcripts,
5 stuck rows had no toolUseId and 8 more named a Task call made inside a
sibling's session; none of the 13 has a recoverable tool_result in the
parent.

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

* fix(server): admit only declared Claude subagents on replay

* test(server): cover Claude workflow subagent accumulation

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-05 23:23:00 +02:00
Kevin Aspesi fb5cfb9fb9 Release the audio session when voice capture and playback go idle (#2866)
* Release the audio session when voice capture and playback go idle

Opening Paseo permanently paused music playing in other apps. Once the
mic had been used a single time, every subsequent foreground of the app
killed the user's audio again until the app was force-quit.

iOS AudioEngine.setupAudioSession() takes .playAndRecord/.voiceChat — a
non-mixing category — and calls setActive(true), but the only
setActive(false) was reachable from tearDown(). Nothing handed the
session back after a normal dictation turn, so iOS re-asserted the last
category the app set on every foreground. The iOS module had no
app-lifecycle handling at all, while Android already had a lifecycle
listener; that asymmetry is what made the platforms diverge. Android had
the same leak in a different shape: AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
was requested per recording and never abandoned.

Add releaseAudioSession() to both platforms and call it once capture
stops and the playback queue drains. iOS pauses the engine, calls
setActive(false, .notifyOthersOnDeactivation) — this is what tells the
other app to resume — then drops to .ambient/.mixWithOthers so any
implicit reactivation mixes instead of interrupting. The session is
re-acquired lazily on toggleRecording(true)/playPCMData, so voice keeps
working; resumeRecordingAndPlayer() re-activates before restarting the
engine, which keeps post-interruption resume intact. Releasing is
guarded on isRecording/isPlaying, and checkEngineIsRunning() now refuses
to start the engine while the session is released, since starting it
would silently re-take the session.

The JS wrapper probes the native module before calling, because a JS
bundle shipped over EAS Update can land on a binary predating this
function.

* Correct the iOS signing diagnosis in mobile-testing docs

The "No Accounts" error does not mean no Apple ID is configured. The
account can be registered in Xcode while its Xcode-Token keychain item is
missing, which Xcode reports as No Accounts and then compounds by falling
back to a stale cached profile — surfacing as a certificate mismatch that
sends you chasing the wrong thing.

Document the DVTDeveloperAccountManager log line that names the real
cause, the two commands that check what is actually configured, and the
build-unsigned-then-sign-manually stopgap.

* Reacquire Android audio focus when playback restarts

releaseAudioSession() abandons the AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
request and pauses the AudioTrack once capture and playback go idle, but
nothing took either back. The next playback turn therefore ran with no
focus held, so the assistant's response competed with whatever the user
was listening to instead of interrupting it.

Add acquireAudioSessionIfNeeded(), the mirror of the iOS
activateAudioSessionIfNeeded() already called from playPCMData, and use
it from both playPCMData() and resumePlayback(). A null audioFocusRequest
is what "we released" looks like, so it doubles as the guard against
re-requesting focus mid-turn.

Reported by Greptile on #2866.

* Make Android playback focus reacquisition non-fatal

The previous commit dropped PCM data when focus reacquisition did not
return AUDIOFOCUS_REQUEST_GRANTED. That is worse than playing unfocused:
playPCMData is fire-and-forget from JS, and playAudio() settles on a
duration timer rather than a native ack, so the runtime acknowledged and
discarded a chunk that was never queued.

It also broke delayed focus. The request is built with
setAcceptsDelayedFocusGain(true), so AUDIOFOCUS_REQUEST_DELAYED is a
normal outcome, but routing it through handleAudioFocusBlocked() abandoned
the pending request and the later grant could never arrive.

Request focus, then play regardless of the result, and leave the request
outstanding so a delayed grant still lands via the listener.

Reported by Greptile on #2866.

* Track scheduled buffers so iOS actually releases after playback

releaseAudioSession() guarded on !speechPlayer.isPlaying, but
AVAudioPlayerNode.isPlaying stays true from play() until an explicit
stop()/pause() — draining every scheduled buffer does not clear it. The
normal completion path is processQueue() -> releaseSessionIfIdle(), and
stopPlayback() is only reached from the explicit stop() API, so after the
first spoken response the guard blocked every release and the session
stayed active. The music-pausing bug returned in full for anyone using
voice responses.

Count buffers that are scheduled but not yet rendered, decrementing from
the .dataPlayedBack completion handler, and guard on that instead. The
handler runs on an AVAudioEngine-internal thread, so the count is behind a
lock. Both stop() paths reset it, since stop() discards what is scheduled
and a stale count would block every later release.

Found by Codex review on #2866.

* Stop re-taking Android audio focus on every foreground

ExpoTwoWayAudioLifeCycleListener calls resumeRecordingAndPlayer() from
onResume, and that requested AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE before
looking at isRecordingBeforePause. Foregrounding the app while nothing was
recording therefore took exclusive focus back and re-paused the user's
music — the same "every foreground kills my music" symptom this change set
exists to remove, just reached through the Android lifecycle instead of the
iOS category.

Return early unless capture or playback was actually live when we
backgrounded.

Found by Codex review on #2866.
2026-08-05 22:53:23 +02:00
0728148657 Add Korean UI localization (#2895)
* Add Korean UI localization

Refreshes the Korean translation from #1544 against the current client locale surface and adds cross-platform locale detection coverage.\n\nCo-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com>

* fix(i18n): preserve reviewed Korean status labels

Restore the wording corrections contributed in the original Korean localization work and lock them with exact-copy assertions.

Co-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com>

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com>
2026-08-05 22:47:33 +02:00
paseo-bot[bot] 94b57f34ad fix(pi): render delegated tasks with lifecycle status (#2891)
Pi left task and direct subagent tool calls in the unknown detail shape, so clients rendered raw request and result payloads. Normalize execution calls at the provider boundary so the existing task card owns their running and terminal states.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-05 22:17:12 +02:00
Mohamed Boudra 5012607643 test(app): assert the host badge icon against the foreground identity colour
The badge icon moved from `identityColor` to `identityForeground` when the
status palette was unified, because a stroked glyph on a surface has to clear
contrast against that surface rather than behind white letters. The helper
still expected the fill table, so it read teal as #368080 where the icon now
draws #3e6d6c.

The variant is per-scheme and there is no scheme to read from a Playwright
locator, so the expectation pins "light" and says why.
2026-08-05 22:12:23 +02:00
Mohamed Boudra 076d9751b7 docs: define Service against Workspace script in the glossary
The sidebar row narrowed to services only and its display-preferences item is
now labelled Services, while the panel and menu that list both types are still
Scripts. That is two words in the UI for things a reader will assume are one,
which is exactly what this file exists to settle.
2026-08-05 21:42:07 +02:00
Mohamed Boudra 5d0560d4c1 feat(app): add a checks display selector and name running services in the sidebar
Checks stopped being a toggle. Showing the icon without its word is a real
third answer rather than half of "off" — the state stays on the row for a
glance and a passing workspace stops paying a word for it on every line — so
it left the boolean row-item record instead of widening it, and a stored
`sidebarRowItems.checks === false` migrates to the new "none".

The services item narrowed to actual services. It used to fall back to any
running command, but a test watcher running says nothing about whether a
workspace is up, and the item now says "Services" so drawing a terminal glyph
under that label was a control lying about itself. It reports the name rather
than the port because the name is what you called it. `health` was already on
the wire and nothing in the sidebar read it, so a failed check now turns the
whole item danger; where several services run, the failing one takes the
single slot, since a healthy one is the unremarkable case and any of them
says "this is up".

That narrowing is why the rename went through the code and the persisted key
rather than stopping at the label: the concept genuinely changed, and a UI
saying "Services" over a `scripts` key is the synonym docs/glossary.md calls a
design error. Both key migrations are tagged COMPAT.

The rest is the sidebar row's geometry. The show-more and new-workspace rows
each carried their own smaller, inset, less-rounded version of a workspace row
— 24pt of left margin, a 14pt icon column, `borderRadius.md`, one fill for
hover and press — which under a column of real rows reads as a mistake rather
than a distinction. They now take the workspace row's geometry, and the
show-more row asks its caller whether it is ending an indented list, because
the two groupings disagree.

Group spacing moved from margin to padding so a collapsed group gives it back
instead of staying spaced for content that is gone. SPACING gained a 0.5 rung
for the 2pt gap between rows.
2026-08-05 21:42:07 +02:00
Mohamed Boudra 211489c658 refactor(app): unify status colours on one token family
Three systems were reporting the same states: a full and a muted status
tier in the theme, plus raw palette steps in StatusBadge and
getStatusDotColor. A PR icon was muted in the sidebar and full-strength
in the PR panel, and the Resolved badge used a theme-independent green
so it painted a dark block in light mode. The muted tier is deleted and
every status surface now reads the one token per signal.

Dots keep a band of their own. A dot is a few points of solid colour
with no shape to read and no label attached, and the running one pulses,
so at the status band's lightness it sat below the check icons and host
badge on its own row — backwards, since the dot is the row's state.
Chroma caps at 90% of gamut rather than 100% because at the edge green
reaches C=0.215 while blue manages 0.116 and the set stops reading as
one family; dark lightness caps at 0.72 because red desaturates to pink
above it.

Host badges needed a foreground variant of the identity table. That
table is tuned for a white letter on the colour as a fill; used as text
it cannot clear 4.5:1 on both themes at once, since light needs
luminance <= 0.183 and dark needs >= 0.212. The new set is generated on
the status family's exact lightness and chroma fraction, so a host name
and a CI check in the same meta row carry equal weight instead of the
brighter one winning the row.

Also: PR avatars had their own eight-hex palette and hash duplicating
identity-colors; workspace rows under a project group now indent to the
same rail as status-grouped rows; group headers are muted; and the
pulsing dot is faster and fades further.
2026-08-05 21:42:07 +02:00
Mohamed Boudra 48bdc5610f fix(app): preserve active host connection choices (#2905)
Settings lost the workspace host when entering the app-wide route, while desktop bootstrap could restore a removed local connection before the saved registry was ready. Use the remembered workspace host for Settings and only register the managed daemon when its server is unknown after registry hydration.
2026-08-05 20:26:41 +02:00
Mohamed Boudra 781274ed1a Skip Git-ignored paths in workspace suggestions (#2902) 2026-08-05 18:20:16 +02:00
Mohamed Boudra 1c43e4eea1 feat(website): offer the beta channel on the download page
Beta testers had to find prerelease assets on GitHub by hand. The
download page now carries a Stable/Beta switch, with stable staying the
default and the canonical URL.

The channel lives in the URL (?channel=beta) rather than client state so
maintainers can link someone straight to it. That link outlives the beta
it was shared for, so the switch only renders while the newest
prerelease leads stable on its core version, and a stale ?channel=beta
falls back to stable instead of serving a promoted version twice.

"What's new" deep-links the minor group anchor (/changelog#release-0.3),
not the exact entry: promotion rewrites the -beta.N entry in place, so
that anchor dies while the group survives — and the group is the
stable-to-beta diff a beta downloader came to read. A version missing
from the bundled changelog links plain /changelog rather than a dead
anchor, which covers the window between a tag going public and the site
redeploying.

Homebrew, the app stores, and the web app have no beta, so the Beta view
drops those rows instead of showing inert placeholders.
2026-08-05 16:39:59 +02:00
paseo-ai[bot] aae9a474c2 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-05 13:39:50 +00:00
Mohamed Boudra d227ff3e83 chore(release): cut 0.3.0-beta.2 2026-08-05 15:30:50 +02:00
Mohamed Boudra 7808a2a7d8 docs: update 0.3.0-beta.2 changelog 2026-08-05 15:27:07 +02:00
Mohamed Boudra 669253bdf6 fix(app): keep terminal output subscribed after focus changes (#2896)
The mobile terminal stream readiness work made live focus and renderer state recreate the stream controller. A replacement controller could remain untargeted, so input reached the PTY while output stopped reaching the terminal. Keep the controller scoped to the client connection and expose current render state through stable callbacks.
2026-08-05 15:19:45 +02:00
Mohamed Boudra 0b624b5585 docs: add Hub public API reference 2026-08-04 23:34:41 +02:00
Mohamed Boudra dfd18a44a7 Document Hub and let docs pages nest (#2864)
* feat(website): nest docs pages under landable groups

The docs sidebar could already render groups from subdirectories in
public-docs, but nothing used it because every doc was a flat file, and
two things stopped it being usable.

A group had no page of its own, so its label was only a toggle and its
name came from the directory name via formatLabel. A directory's
index.md now supplies the group's label, order, and href, which makes
the group landable and lets the label be authored rather than derived.

Categories and top-level directories named the same thing, so a
categorised doc at hub/index.md produced a group nested inside its own
category. Only directories below the top level become groups now.

Also fixes two behaviours in the sidebar:

- The open/closed effect assigned containsActive on every change, so a
  group you expanded to browse collapsed the moment you navigated
  anywhere else. Navigating in now opens a group and navigating away
  leaves it alone.
- The sidebar scrolls independently of the page and never scrolled the
  active entry into view, so loading or refreshing a page far down the
  list left the sidebar parked at the top with no visible position.

The group row is a single target: it navigates and expands, and
collapses when clicked again from the page it points at. A separate
chevron button would put two actions a few pixels apart.

* docs(hub): document Hub

Adds a Hub section covering what it is, how it works, daemons,
triggers, configuration, activity, and the two ways to run it.

The section separates using Hub from running it. Overview, quickstart,
daemons, triggers, and configuration hold regardless of who operates
the deployment; the self-hosting branch owns deploying the service and
creating the GitHub, Slack, and Discord apps it talks to. That split
falls on a real seam, because those apps are exactly what a hosted Hub
would own on your behalf. Hosted is a placeholder until it ships.

Each provider gets a trigger page carrying its events, filter
semantics, and the full paseo.event.* reference for the data a prompt
can read. That data is the thing a config author actually needs and
the thing they cannot get from anywhere else.

Every YAML block was parsed against the Hub config schema rather than
eyeballed, which kept several plausible-looking things out of the
docs: worktree prNumber takes a literal number and not a merge
variable, only daemon environments run today, and github.push has no
actor so it can never satisfy the required from_users filter.

Troubleshooting moves to order 90 so it stays last.

* refactor(website): let the active docs entry scroll itself into view

The layout queried the sidebar for the active entry after every
navigation, which coupled it to when that entry mounts. A link inside a
collapsed group only mounts once the group's own effect opens it, so
the query could run against a DOM that does not contain it yet.

Moving the scroll to the entry removes the ordering question rather
than answering it, and drops the ref and the cross-component query.

Reported by Greptile on #2864. The failure it described does not
reproduce: navigating from a page outside a collapsed group into one of
its children still scrolls correctly, verified with the group confirmed
collapsed and the sidebar forced to the top. The coupling was real even
though the bug was not.

* docs(hub): stop listing BETTER_AUTH_URL as required

It falls back to PASEO_CLOUD_PUBLIC_URL, so a deployment that sets the
public origin already has a correct auth base URL. Listing it as
required made the Fly recipe look incomplete when it was not.

Moved to optional with its default, and recorded what actually breaks
without BETTER_AUTH_SECRET: the auth routes close and nobody can sign
in.

Reported by Codex on #2864, which read the same inconsistency the other
way round and proposed adding the variable to the Fly example.

* docs(hub): attach a database in the Fly recipe

The Fly section showed inline config and a secrets command, neither of
which supplied DATABASE_URL. It defaults to localhost:5432, which on
Fly reaches nothing, so the documented deployment could not start.

fly postgres attach sets DATABASE_URL as a secret, so the step is the
attach itself rather than another variable to copy.

Reported by Codex on #2864.

* docs: align Hub self-hosting contract
2026-08-05 03:58:27 +08:00
paseo-ai[bot] d7f72d2805 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-04 19:39:00 +00:00
Mohamed Boudra 9916e3d185 chore(release): cut 0.3.0-beta.1 2026-08-04 21:27:12 +02:00
Mohamed Boudra 34c538602a docs: add 0.3.0-beta.1 changelog entry 2026-08-04 21:23:17 +02:00
Mohamed Boudra 62f8b14804 fix(desktop): keep dev artifacts out of the packaged app 2026-08-04 21:23:10 +02:00
Mohamed Boudra 8ac7cd476c test(server): remove archive watcher race (#2868)
The archive response already waits for durable archival and owned-worktree cleanup. Assert those final states directly instead of racing a separate filesystem watcher with its own fixed timeout.
2026-08-04 19:32:28 +02:00
Mohamed Boudra d191fa6685 feat(app): automate iOS beta distribution
Beta tags now reach external testers without per-release dashboard work. Reserve distinct native build slots so successive betas and the promoted stable build can coexist under one app version.
2026-08-04 19:21:22 +02:00
Mohamed Boudra adf508a1c8 fix(app): stabilize native terminal rendering
Scale custom glyph paths in measured cell coordinates so box-drawing runs fill their cells on Android. Debounce daemon resize claims at the terminal pane boundary so keyboard layout animation sends only the settled dimensions.
2026-08-04 18:43:46 +02:00
Mohamed Boudra dac255cb99 Merge remote-tracking branch 'origin/main' 2026-08-04 15:46:08 +02:00
Mohamed Boudra 7dbeff8290 perf(app): reduce startup websocket work
Make provider catalogs compact and content-addressed so repeat launches can reuse the persisted snapshot instead of transferring it again. Remove eager global catalog loading and the stopped-agent defensive tail fetch, while keeping sequence-gap and reconnect recovery as the correctness paths. Add an opt-in production-ID Android profiling build for local startup traces.
2026-08-04 15:45:55 +02:00
Mohamed Boudra 8e5b6876b4 fix(app): preserve assistant order across outline jumps
Live Markdown block promotion discarded timeline positions, so merging a disjoint history window could sort the final block ahead of earlier blocks. Preserve the source position on every promoted block so timeline merges keep the response intact.
2026-08-04 14:46:30 +02:00
Mohamed Boudra 9194449952 test(app): stabilize sidebar E2E assertions 2026-08-04 14:06:40 +02:00
paseo-ai[bot] 1e392557f2 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-04 11:21:13 +00:00
Mohamed Boudra b020a1d1ed Merge remote-tracking branch 'origin/main' 2026-08-04 13:13:22 +02:00
Mohamed Boudra 7b6965856b Prevent workspace file watching from stalling the daemon (#2858)
* fix(server): pin workspace file watchers to native backends

Backend auto-detection could select an IPC watcher whose subscription lock deadlocked the Node worker pool during watcher reconfiguration. Select the host-native backend explicitly so filesystem work, logging, and relay DNS cannot be starved by that path.

* test(server): cover native watcher production boundary

Route the existing real filesystem observation test through the same subscription function used by the daemon. This proves native watcher selection and reconfiguration with actual file events instead of only testing the platform selector.

* test(server): synchronize native watcher readiness

Replace fixed startup sleeps with a real tracked-file event barrier. Native backends can resolve their subscription before the initial stream is fully drained, so the integration test now begins assertions only after an event has been delivered and refreshed.

* fix(server): measure native workspace watchers
2026-08-04 13:09:30 +02:00
Mohamed Boudra 238047eaa8 fix(app): align compact menu sheet content
Route composer attachments through the shared compact menu sheet so headers and rows use the same alignment rails. Base the final-row padding on the device safe area to keep actions clear of system navigation.
2026-08-04 13:09:26 +02:00
Mohamed Boudra 683f38443c fix(app): trace the row's corners with its press highlight
The highlight is an overlay inside the pressable rather than a background on
it, because the node Reanimated animates cannot also carry a themed Unistyles
style. An overlay inherits nothing, so a rounded sidebar row flashed square.

Take the corners from the pressable's own style. The row already declares its
shape once, and a highlight that restated it would be a second copy free to
drift; highlightStyle is left meaning only what the highlight is painted with.
Corners apply before it, so a caller can still depart from the row's shape.
2026-08-04 12:05:34 +02:00
Mohamed Boudra bfec5a05b9 feat(app): open the workspace actions menu as a sheet on compact
The trigger was a vertical ellipsis on compact and a horizontal one elsewhere.
Use the horizontal glyph everywhere: it is the wider shape, and it is the one
sitting above a menu that now presents from the bottom edge.

The button draws at 32pt, under the 44pt touch minimum, and three of them sit
flush beside the title, so the slop can only grow vertically — widening it
would put two buttons' hit areas over the same pixels.
2026-08-04 12:05:18 +02:00
Mohamed Boudra 8b9314b70a feat(app): say where a workspace lives in the compact header
Step the title and project name down one rung of the scale on compact, and put
the host on the project line. On a phone this header is the only thing on
screen that names the machine, because the sidebar carrying that badge is
closed. It follows each host's own badge setting, so a local-only setup stays
quiet, and it yields space before the project name does.

A project name that only repeats the workspace name is still dropped where the
two sit side by side, and kept on compact, where the line exists for the host
anyway. That made shouldShowSubtitle a verdict the header source was not in a
position to reach, so it now reports the fact: isSubtitleDistinct.

Drop the 60% cap on the project name. It resolved against a box whose own width
came from that same text, so it clipped while there was still room beside it;
flexShrink already gives up space only when there is none.
2026-08-04 12:04:51 +02:00
Mohamed Boudra 22cb7f9996 refactor(app): give the host badge one home
The server glyph, its identity-colour table and the muted label lived inside
the sidebar meta row, and the three stores that decide whether a host has a
badge at all were assembled at one call site. Both are about to have a second
caller in the workspace header.

Move the badge to hosts/host-badge and the resolution to useHostBadges. The
badge now owns its glyph size, and the meta row matches it, since it is the one
item on that line that also appears off it.
2026-08-04 12:04:32 +02:00
Mohamed Boudra bf6cd09d6c fix(app): run menu selections that close the menu on iOS
Selecting an item parked its action in a ref and waited for the Modal's
onDismiss to flush it. MenuOverlay returns null as soon as the menu closes, so
the Modal unmounted and onDismiss never fired: every close-on-select action was
dropped. Creating a terminal from the compact workspace menu did nothing at all.

Wait on a timer instead. The grace period was always about giving UIKit time to
tear the surface down before a native presenter launches, and a callback fired
from inside a subtree being removed is racing its own removal. The sheet had no
flush at all, so it was broken the same way for a different reason.
2026-08-04 12:03:00 +02:00
Mohamed Boudra 56cb70fa59 feat(app): size menu rows for the input driving them
Rows are 40pt below md and 28pt above it. The split is on breakpoint rather
than presentation because the compact popover, which compactMode defaults to,
is worked with a thumb just as a sheet is.

Move vertical spacing off the row onto a new MenuPage, which also provides a
page's depth so the popover and the sheet cannot disagree about what a page is.
A row carrying vertical margin would serve both the edge inset and the
inter-row gap at once, and margins do not collapse.
2026-08-04 12:02:49 +02:00
Mohamed Boudra dc6f13e31e fix(app): render terminal art at exact cell geometry
Native font glyph bounds do not reliably fill terminal cells, and long box-drawing runs accumulate advance drift. Paint block and box glyphs from cell geometry while preserving the original Unicode cell model for selection and copying.
2026-08-04 11:00:07 +02:00
Mohamed Boudra 32705cb3f2 fix(app): acknowledge native sidebar presses immediately
Sidebar rows previously waited for JavaScript-driven selection or navigation before showing feedback. Keep the transient highlight on the UI worklet while leaving activation, cancellation, and drag ownership with the existing pressable.
2026-08-04 10:47:42 +02:00
Mohamed Boudra e90832eedd fix(app): preserve native IME composition state
Clearing the hidden terminal input after multi-character commits can leave the keyboard's composing buffer intact, causing later changes to replay committed text. Keep the accumulated native value so subsequent changes emit only their appended suffix.
2026-08-04 10:44:00 +02:00
df32e6ddcd Fix the CI failures on main (#2850)
* fix(app): keep an open workspace menu from unmounting its own trigger

The sidebar kebab is revealed by hover, and on a compact layout the menu it
opens is a sheet that slides up over the row it came from. The row un-hovers,
the trailing overlay unmounts the trigger, and the menu's open state goes with
it — nothing is left that can close the surface already presented in the
portal, so a full-screen backdrop swallows every click until reload.

Lift the menu's open state to the component that decides whether to render the
kebab, and keep the kebab rendered while the menu is up.

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

* test(app): follow the sidebar and host badge redesigns in the e2e suite

Three specs were still driving surfaces that moved out from under them, so CI
has been red on main since the sidebar polish landed.

Display preferences became one row per decision with the options a page down,
so grouping and the host filter need that page walked first. Five specs had
their own copy of the two-line dance; it lives in the sidebar helper now, and
the host filter has its own opener next to the menu it belongs to.

The host badge is plain secondary text on the meta line and its identity colour
lives on the server icon alone, so the helpers stop asserting a tinted label
and a pill fill that the redesign removed.

Alignment rails are compared across canvas glyph metrics and SVG bounding
boxes, so the residual depends on the host's font; a 1px and a 0.5px tolerance
both landed on the wrong side of it in CI. Two pixels still catches the whole-
pixel offsets these rails encode. The compact sheet spec also read its rails
while the panel was still sliding in.

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

* chore(app): let a browser e2e run take a private Metro cache

Metro's on-disk cache is shared by every checkout on the machine. Editing one
app source file rebuilds that module against everyone else's cached output and
the Unistyles styles can come back desynced, which surfaces as alignment specs
failing with real-looking pixel numbers that go away when the edit is reverted.

E2E_METRO_CACHE_VERSION namespaces a run's cache without deleting anyone
else's entries, so the cache can be ruled out before a layout failure is
believed.

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

* test(app): stop the chat scroll helper from hanging on a stalled sampler

`scrollChatAwayFromBottom` waited on a promise that could only ever resolve
from a `requestAnimationFrame` sampler started by a `wheel` listener. rAF does
not fire while the page is occluded, which is reachable on a CI runner, so the
sampler could stall on its first frame and the wait never returned — the test
died on its own 240s timeout with nothing to read. A missed wheel event did the
same.

Settling the scroll before measuring is an optimization; the assertion after it
is what decides the test. Sample on a timer with a deadline so a stall falls
through to that assertion instead of consuming the test.

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

* test(app): remove layout geometry e2e coverage

Rail and pixel-alignment assertions depend on host rendering details and should not define the browser E2E contract. Preserve the surrounding behavior coverage while dropping the Metro cache escape hatch that only supported diagnosing those assertions.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-04 10:29:52 +02:00
Mohamed Boudra e238fea158 feat(app): make mobile terminal selection unmistakable
Use an opaque high-contrast selection treatment so touch selection has an immediate, unambiguous visual state.
2026-08-04 10:15:56 +02:00
Mohamed Boudra 98c9878c57 fix(app): keep composer above Android keyboard
Pin the keyboard controller to the release that prevents stale keyboard positioning from leaving the composer underneath a keyboard that is still visible.
2026-08-04 09:33:20 +02:00
Mohamed Boudra fb657ebc20 Default new worktrees to the upstream branch (#2848)
* fix(workspaces): default new worktrees to upstream refs

Use the daemon-resolved upstream as the untouched starting ref so unpushed local commits do not leak into new workspaces. Preserve exact base identity for fork remotes while keeping local-first behavior across version drift.

* fix(workspaces): preserve exact base refs in git actions

Resolve accepted display aliases back to the stored ref before comparisons and updates. Fail closed when a non-origin remote base has no explicit mutable merge target.

* fix(workspaces): reject conflicting qualified base refs

Display aliases remain accepted for compatibility, but a qualified ref carries remote identity and must not be canonicalized to a different stored upstream.

* test(server): make upstream fixtures portable

Keep the checkout-status fixture aligned with the optional upstream field, and select main explicitly when cloning a bare test remote whose default HEAD varies by platform.

* fix(workspaces): preserve exact upstream refs

Qualified remote refs now fail closed when pruned instead of switching lineage. Metadata accepts Git-valid upstream names rather than applying a narrower character whitelist.

* fix(workspaces): preserve qualified local base refs

Local tracking branches are exact upstream identities too. Keep heuristics only for legacy unqualified metadata so origin cannot replace a stored refs/heads base.
2026-08-04 09:01:38 +02:00
Mohamed Boudra 74dea38456 docs: add connectivity guide 2026-08-04 00:42:34 +02:00
Mohamed Boudra 3a9d39b63a Merge branch 'main' of github.com:getpaseo/paseo 2026-08-03 23:59:39 +02:00
Mohamed Boudra ec00d57e01 fix(app): align workspace file tree rows
Keep the Files and Changes trees on shared icon, label, and trailing rails while applying the directory-chevron optical adjustments at the owning row.
2026-08-03 23:47:29 +02:00
Mohamed Boudra b4a4507fb2 feat(app): keep recently viewed chats live (#2842)
Replace time-based subscription expiry with a bounded hot set so switching among a small working set and backgrounding the app do not force stale timeline catch-up. Visible chats always retain priority, while disconnect and legacy delivery preserve their existing authoritative boundaries.
2026-08-03 23:18:28 +02:00
Mohamed Boudra f25e68b8cc Preserve chat scroll position across workspace switches (#2838)
* fix(app): preserve chat scroll across workspace switches

Hidden retained panels can emit geometry-driven scroll events while collapsed, which previously looked like user intent and disabled output following. Keep the retained DOM mounted, suspend its inactive scroll work, and only detach from the bottom after explicit input.

* fix(app): classify chat scroll intent at input

A held pointer is not evidence that a geometry-driven scroll was user initiated. Detach output following directly from upward wheel, key, touch, or mouse-drag input, and keep ordinary scroll events responsible only for metrics and bottom reattachment.

* fix(app): keep editing keys out of scroll intent

Question inputs render inside the chat viewport, so their caret-navigation keys bubble through the same keyboard boundary. Treat keys from editable descendants as editing intent while preserving viewport-level keyboard scrolling.

* fix(app): exclude zoom gestures from scroll intent

Ctrl-wheel and trackpad pinch events can carry a negative wheel delta without scrolling chat content. Keep those browser zoom gestures from disabling output following.

* fix(app): require pointer-driven chat movement

Mouse movement alone is not viewport scroll intent. Correlate actual upward scroll with short-lived content-drag evidence or a scrollbar-gutter gesture, while excluding embedded editors and retaining geometry-only following.

* fix(app): recognize overlay chat scrollbars

* fix(app): scope scroll intent to chat viewport

* fix(app): rearm chat history at scroll start

* fix(app): track chat autoscroll intent

* fix(app): separate pointer scroll gestures
2026-08-03 23:08:11 +02:00
Mohamed Boudra a361d1cb2f fix(app): polish workspace navigation surfaces
Use mobile action sheets and long-press file menus while preserving provider context across sheet portals. Keep sidebar and file-pane glyphs on shared visual rails, avoid scrollbar layout shifts, and hide keyboard affordances where shortcuts are unavailable.
2026-08-03 21:40:21 +02:00
Mohamed Boudra ab8151d33f feat(app): sidebar display preferences, a shared menu engine, and a colour pass
Sidebar polish round.

Menus. `dropdown-menu` and `context-menu` were two copies of one engine that
differed only in how the trigger fires — identical from `computePosition` down —
and neither could nest. Both are now thin wrappers over
`components/ui/menu/`, which owns anchoring, a pure page-stack navigation model,
and one presentation fork: flyout on pointer, pushed pages in a sheet on compact.
All 31 call sites are unchanged. Items got a rounded inset fill, a visible
separator, and `selected` (a check) split from `active` (a fill).

Sidebar display preferences. The flat wall of four sections becomes one row per
decision with its value, and per-feature toggles over what a workspace row shows:
host, pull request, checks, scripts. Each is independent — a ticked toggle that
draws nothing is a lie. `display-preferences/` hides that grouping lives in
zustand while title source and row items are app settings.

Timestamps. "Last activity" now ticks itself. The label rounds to the minute and
never shows seconds; past a week it becomes a date. The timer lives in the leaf
`<Text>`, at most one per resolution tier shared by every row, and a tick only
re-renders when the string actually changed — so a memoized row that legitimately
never re-renders still stays current.

Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as
the muted tier, lightness and hue untouched. File icons are pulled toward neutral
in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a
tree where the icon is the only colour on the row, and ours is not. New/deleted
files use the forge square-plus/square-minus glyphs at muted weight instead of
tinted pills. Project icons round a little more; uploaded icons never do.

The project status badge is a knockout again: it fills with the colour of the row
behind it, so the ring reads as a gap in the icon rather than a white halo.

File rows. The always-visible kebab is gone from the file explorer and diff tree
in favour of right-click, which also reclaims the reserved action slot.
2026-08-03 18:58:06 +02:00
Mohamed Boudra 777fe968f5 Keep Android terminal keyboard input stable (#2830)
* fix(app): stabilize native terminal keyboard input

Terminal focus requests blurred and remounted the hidden input even when the software keyboard was already visible, causing visible IME cycles and leaving clipboard commits coupled to stale native text. Keep focus requests idempotent while resetting only the native input buffer.

* test(app): remove misleading keyboard device scenario

Agent Device cannot durably assert software-keyboard continuity or commit a keyboard clipboard suggestion, so the scenario overstated coverage already owned by focused input-policy tests.

* fix(app): derive terminal keyboard focus from visibility

Use the keyboard-controller inset as the source of truth so a system-dismissed keyboard cannot leave focus activation suppressed. Avoid a duplicate focus request while explicitly showing the keyboard.

* fix(app): track terminal keyboard visibility across layouts

Keep keyboard visibility independent from compact-only terminal padding so tablet taps remain idempotent while the software keyboard is open.
2026-08-03 18:32:16 +02:00
Mohamed Boudra ed4fc08f78 feat(website): replace the Cloud page with Paseo Hub
The Cloud page pitched a hosted multi-user product and collected emails
through a signup form. The Hub is a self-hosted service in private beta
and the only way in is a Discord DM, so the signup server function and
its webhook are deleted rather than repointed. /cloud now 301s to /hub
from the worker entry, which already owns the site's permanent path
redirects, so old links and search results keep working.

The homepage also drops split panels, service proxy, voice, and keyboard
shortcuts. They had turned the page into a feature list; it should show
what Paseo enables at a high level.

Brand and provider icons move into shared modules so the Hub page, the
site header, and the landing page each use one copy instead of three.
2026-08-03 14:08:59 +02:00
Mohamed Boudra 4406bf588e fix(app): preserve host badge identity
Keep host badges visible under row pressure and apply the selected host color consistently to the icon, label, and tinted background.
2026-08-03 14:08:59 +02:00
Mohamed Boudra 5c7623997c feat(hub): pass execution MCP servers to agents (#2813)
* feat(hub): pass execution MCP servers to agents

Hub-owned execution capabilities need to reach provider sessions without becoming part of outward agent snapshots. Keep the configuration daemon-private so provider recovery continues to work.

* fix(hub): protect daemon MCP namespace

Hub execution MCP configuration must not replace the daemon-owned Paseo server. Validate only after owned-record lookup so idempotent replay remains stable while new launches fail before provider or durable state creation.

* fix(agent): reject unsupported MCP launches

* fix(agent): scope MCP support guard

* fix(agent): redact MCP metadata from wire payloads

* fix(agent): validate MCP support on restored sessions

* fix(session): rehydrate MCP config on resume

* fix(session): resume newest matching agent record

* fix(session): restore archive state after failed resume
2026-08-03 13:49:53 +02:00
Mohamed Boudra adf33930b3 Preserve assistant selection formatting when copying (#2808)
* fix(app): preserve formatting when copying assistant selections

Browser selection serialized React Native Web layout nodes instead of the assistant message semantics. Recover semantic markup from renderer-owned metadata and reuse the rich clipboard generator used by Copy turn.

* fix(app): preserve assistant copy semantics

Carry source-only Markdown metadata through the selection copy boundary, unwrap generated code links, and cover ordered starts, hard breaks, and fence languages in Playwright. Refresh the Nix dependency hash for the added clipboard dependency.

* fix(app): preserve partial selection context

Wrap selected fragments with their shared semantic ancestor chain and enable GFM table and strikethrough conversion. Cover partial nested selections and GFM output through the browser clipboard.

* fix(app): preserve copied list and autolink text

Adjust partial ordered-list starts for omitted siblings, strip parser-generated anchors before Markdown reconstruction, and refresh the Nix dependency hash computed by CI.

* fix(app): normalize partial selection context

Normalize cloned ordered lists across block boundaries, degrade incomplete table fragments to selected text, and preserve table alignment provenance through Markdown reconstruction.

* fix(app): escape copied table delimiters

Rendered table cells no longer retain source escape characters. Escape literal pipes during DOM-to-Markdown conversion so copied tables preserve their column structure.

* fix(app): preserve authored link semantics

The rendered link boundary conflated parser-generated links with authored autolinks and discarded authored titles. Carry title semantics into the anchor and unwrap only links with explicit generated provenance.

* fix(app): preserve nested copy structure

Blank-line splitting separated loose nested lists before Markdown rendering, leaving no nesting for clipboard serialization to preserve. Derive protected separators from the Markdown block structure and keep copy metadata independent from monospace surface styling.

* fix(app): preserve selected file links

Assistant messages and rich clipboard HTML must apply the same link validation policy. A shared parser keeps file links semantic in both MIME representations while retaining markdown-it's unsafe-scheme rejection.

* fix(app): preserve partial table content

A selected body row wider than its retained header cannot be represented as GFM and is truncated when rich HTML is regenerated. Treat only those over-wide fragments, plus headerless fragments, as incomplete while preserving valid short rows.
2026-08-03 19:02:31 +08:00
Mohamed Boudra 04fe30a3e2 Improve mobile terminal input, selection, and resizing (#1607)
* Improve mobile terminal typing and selection

* Address mobile terminal review feedback

* Remove dead native terminal input branch

* Replay bracketed paste in terminal preamble

* Stop per-key terminal resize claims

* Stabilize terminal resize browser test

* Use tracked terminal mode for native paste

* Restore terminal toolbar focus handling

* Adapt native terminal to active visibility state

* Stabilize native mobile terminal interactions

* Add native terminal fallback boundary

* test(app): expect explicit terminal resize reclaim

* fix(app): stabilize terminal buffer coordinates

* fix(app): restrict terminal resize ownership

* fix(terminal): gate restored input modes

* fix(app): recover stalled terminal WebViews

* chore(app): remove obsolete terminal harnesses

* fix(app): make terminal resize claims explicit

* test(app): stabilize native terminal device flows

* test(app): track absolute scrollback coordinates

* fix(app): isolate terminal WebView streams

* fix(terminal): arbitrate size ownership across clients

The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it.

* test(server): make worker resize assertion portable

Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
2026-08-03 19:01:34 +08:00
Slava GoltserandMohamed Boudra 2948a435ed feat(omp): update context usage during active turns (#2503)
* feat: add DeepWiki MCP server at project level

* feat(omp): poll context window usage during active turns

OMP agents previously reported context window usage only at turn
completion, unlike Claude and Codex which stream live updates. Add a
periodic poll (3s interval) of getSessionStats() that emits usage_updated
events while a turn is active, so the client context window meter
updates in real-time.

* clear context usage poll timer before scheduling next interval

* chore: remove .omp/ directory

* fix(omp): make live usage polling lifecycle-safe

OMP exposes live usage through a stats request rather than stream events. Keep polling scoped to active turns, suppress unchanged snapshots, and invalidate stale completion reads across interrupts, new turns, and session shutdown.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-03 12:35:23 +02:00
Ivan MalisonandClaude Opus 5 5d15e40a2b fix(nix): restore desktop app icon matching (#2506)
* fix(nix): identify desktop app for icon matching

* fix(desktop): ignore wrapper class in CLI passthrough

* fix(nix): give the Electron launcher a real app root

The Wayland app_id — what a bar or taskbar uses to find the .desktop
entry, and therefore the icon — is fixed during Electron startup, so
neither `--class=` nor main.ts's `app.setName()` reaches it on current
Electron. Launching `electron path/to/main.js` gives Electron no
package.json to read a name from, so the window published the default
"electron" app_id and shells drew the generic Electron icon.

Launch a one-file app root named "paseo-desktop" instead, matching the
installed desktop entry and hicolor icon. Older Electron (38) derives
the app_id from the runtime app name "Paseo", so also ship a NoDisplay
alias entry under that name; the icon resolves either way and the
launcher list still shows a single Paseo.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 04:34:05 +08:00
Ivan Malison fdb39d2111 Fix Darwin Nix desktop app icon (#2783)
* Fix Darwin Nix desktop app icon

* Refresh Darwin Nix bundle identity
2026-08-03 04:33:08 +08:00
Mohamed Boudra 2e188787ff Limit daemon-wide Git process pressure (#2797)
* perf(server): bound Git process starts

Git refresh bursts could keep creating short-lived processes at the concurrency ceiling, causing sustained process churn on constrained hosts. Apply one daemon-global start-rate and concurrency policy to every Git command while preserving the legacy concurrency environment variable.

* build(nix): update npm dependency hash

* fix(server): hold Git slots until process exit

* fix(server): enforce Git limits across process lifetime

* fix(server): align Git metrics with process exit

* Fix Git limiter test deadlocks

* Tune default Git process rate
2026-08-02 19:59:43 +02:00
Mohamed Boudra f682156302 Keep background composer work out of creation progress (#2794)
* fix(composer): keep background context out of loading state

Pull request link resolution can block a new workspace until its checkout target is known, but it is not submission work. Keep that safety gate while reserving the send-button spinner and drop lockout for actual submission or upload progress.

* test(composer): cover link resolution at browser boundary

Exercise the real new-workspace composer while pull request context resolves, including the submit presentation and file-drop path. Keep the two presentation booleans local to their only caller instead of exporting policy solely for an internal-state unit test.

* fix(composer): preserve background lookup ownership

Composer edits could release the resolving gate while a pull request lookup still owned the request, briefly allowing workspace creation from the wrong branch. Track logical candidates independently of unrelated draft changes and settle each reference independently so only invalid work releases early.

* fix(composer): preserve pasted PR selection order

* fix(composer): renew re-added PR lookups

* fix(composer): track pasted PR source order

* fix(composer): preserve resolved attachment order

* fix(composer): coordinate active lookup order
2026-08-02 18:37:07 +02:00
Mohamed Boudra ccb668b3b4 Improve sidebar workspace readability and host identity (#2790)
* fix(app): align turn footer actions

Keep running-turn actions in the same position as completed-turn actions and use the shared small icon size for both copy and fork controls.

* feat(app): improve sidebar workspace identity

Make dense sidebar workspace rows easier to scan and let each device control how connected hosts are identified.

* fix(app): keep host badges readable

Use a compact display menu and keep identity color on the host icon so translated controls fit and badge text retains standard contrast.

* fix(app): announce selected sidebar badge mode

* fix(app): retain icon-only host names for screen readers

* fix(app): name hoisted workspace rows

* fix(app): retain host identity in workspace labels

* fix(app): retain workspace status in row labels

* fix(app): defer host appearance until identity resolves

* fix(app): serialize host appearance writes

* fix(app): serialize host registry writes

* fix(app): bound sidebar host badge labels

* test(app): match workspace status labels

* fix(app): consolidate sidebar identity behavior

* test(app): assert status grouping behavior

* fix(app): keep sidebar row actions accessible

* fix(app): stabilize optimistic message layout
2026-08-02 16:58:20 +02:00
Mohamed Boudra 553ac53643 feat(app): add chat outline navigation (#2792)
* feat(app): add chat outline navigation

Long agent sessions need prompt navigation without coupling viewport movement to disposable server windows. Keep fetched timeline pages in client state so repeated jumps stay local, and settle prompt jumps against virtualized row measurement changes.

* fix(app): preserve chat outline timeline coverage

* fix(app): reconcile outline window submissions

* fix(app): harden outline timeline navigation
2026-08-02 20:48:23 +08:00
Mohamed Boudra 0ad51e26a1 fix(app): paste clipboard images on mobile (#2793)
Native composers had no path from the device clipboard into image attachments. Route clipboard data URLs through the existing attachment persistence flow and expose the action only in the native attachment sheet.
2026-08-02 14:46:25 +02:00
Mohamed Boudra 2b474f7df9 Keep workspaces responsive across app-wide routes (#2791)
* perf(app): retain inactive workspace screens

App-wide routes cleared the active workspace selection, which removed the retained workspace deck from the React tree and made returning rebuild every panel. Keep mounted deck entries inactive across those routes and suspend inactive panel subscriptions so state is preserved without background work.

* perf(app): suspend remaining inactive queries

Retained composers and commit diff panels still subscribed through query hooks that did not receive panel activity. Propagate the existing activity gate so app-wide routes leave those hidden surfaces dormant.

* refactor(app): separate panel activity from connectivity

A hidden retained panel is still connected to its host. Keep connectivity truthful, pause work inside query and stream owners, and preserve terminal emulator identity while its stream is detached.

* test(app): target active retained controls

* fix(app): retain compact and terminal route content

* fix(app): hide inactive workspace overlays
2026-08-02 14:32:17 +02:00
Mohamed Boudra 26bdb1d171 Keep timeline history duplicate-free across daemon upgrades (#2789)
* fix(app): keep timeline pagination duplicate-free

Older daemons page projected history by canonical sequence ranges, so one projected block can appear in adjacent responses. Accept each block on the page containing its projection anchor while still advancing across empty projected pages.\n\nProjected siblings can share a provider message ID, so render identity also includes the timeline cursor instead of collapsing distinct blocks onto one layout row.

* fix(e2e): preserve isolated daemon launch contract

Keep the source-daemon spawn path recognizable to the repository launch-contract check while selecting the built supervisor only for published package fixtures.

* fix(app): preserve stable timeline item identity
2026-08-02 12:21:47 +02:00
Mohamed Boudra 0a106dd35f Keep Git status and diffs current across platforms (#2775)
* fix(server): make Git observation cross-platform

Platform-specific watchers and forced full refreshes amplified Git processes and could miss nested changes. Share checkout and repository observation internally, scope final-state refreshes, and bound degraded polling so streamed summaries and diffs stay correct under bursts.

* fix(server): refresh Git worktree state on metadata changes

* fix(server): keep Git watcher runtime inputs current

* fix(server): normalize watcher runtime boundaries

* fix(server): reconcile Git observer topology

* test(server): report Git watcher event batches

* fix(server): close Git observation gaps

* chore(ci): remove temporary Git observation jobs
2026-08-02 12:20:20 +02:00
paseo-bot[bot]andMohamed Boudra f888bae04d Keep workspaces active while native subagents run (#2777)
* fix(server): include native subagents in workspace activity

Provider-native children live outside managed agent snapshots, so background work could outlive an idle parent without keeping the workspace running. Aggregate running child descriptors into the owning workspace and publish workspace updates on child lifecycle changes.

* fix(server): serialize workspace status updates

Queue overlapping descriptor rebuilds per workspace so older activity snapshots cannot overwrite newer lifecycle state. Preserve batched registry reads for multi-workspace updates.

* test(server): update workspace status fixtures

Provide the native subagent activity dependency in the shared session fixture and wait on observable git-watch output across the serialized update boundary.

* fix(server): drop queued workspace updates on subscription swap

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-02 12:16:34 +02:00
Mohamed Boudra 048b82f2a7 Preserve interactive ACP permission choices (#2762)
* fix(acp): preserve permission choices

ACP question bridges encode answers as repeated permission options. Preserve each option through the UI and return the exact selected ID, while keeping auto-accept from choosing on the user's behalf.

* fix(acp): validate selected permission behavior

Reject inconsistent action IDs instead of allowing the action ID to override the response behavior. Move the misplaced browser spec so the current base CI contract can run.

* fix(acp): preserve invalid permission requests

Validate selected ACP actions before consuming the pending request so inconsistent client responses can be corrected and retried.

* test(acp): verify real permission questions

* fix(acp): reject empty permission action ids

* test(e2e): scope Kimi config to ACP spec
2026-08-01 20:56:19 +02:00
Mohamed Boudra 74e4a9f90c Restore browser test routing (#2768)
Move the project status badge Playwright spec into the browser-owned directory so the changes contract passes and the test is collected.
2026-08-01 20:00:32 +02:00
Mohamed Boudra c8de1b165f Fail active agents when their provider process exits (#2757)
* fix(server): fail agents when provider process exits

The transport previously closed pending RPCs without terminating the active manager run, leaving the agent stuck in running state. Propagate unexpected process termination as a one-shot turn failure while suppressing it during intentional session close.

* fix(server): keep idle provider exits recoverable

A process death with no active foreground turn is not a failed turn and must not trigger turn-based lifecycle actions. Leave the session disconnected so the next prompt reconnects without publishing a false terminal event.

* fix(server): preserve provider exit recovery

Connection cleanup after a failed reconnect must not close the durable session boundary or discard AgentManager subscribers. Treat both foreground and autonomous root turns as active when propagating process death.

* test(server): make process exit harness portable

* fix(server): clear permissions after provider exit

* fix(server): preserve plan approval after provider exit

* fix(server): serialize provider reconnects

* fix(server): stop reconnects after session close
2026-08-02 01:53:09 +08:00
Mohamed Boudra 9ddc3e0c42 Show complete native subagent conversations (#2760)
* fix(server): preserve provider subagent messages

The provider SDK defaults to forwarding only child tool activity, which leaves the nested timeline without the child response. Request the full nested transcript so provider subagent panes retain both sides of the conversation.

* fix(app): correct project status badge test imports
2026-08-01 19:07:16 +02:00
Mohamed Boudra 5a30affd7f Skip unrelated required checks without losing their names (#2542)
* fix(ci): skip unrelated required checks cleanly

Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable.

* fix(ci): gate changes to install patching script

* fix(ci): route cross-package test contracts narrowly

* fix(ci): keep routed contracts merge-blocking

* fix(server): exclude nested test utilities from builds

* fix(ci): cover app-owned native and browser domains

* refactor(ci): align tests with runtime domains

Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check.

* fix(ci): keep routing and desktop suites isolated

The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs.

* fix(ci): isolate desktop e2e runtime

* perf(test): isolate Playwright workers

* perf(test): parallelize Playwright within specs

* fix(test): keep Playwright file scheduling

* fix(test): share dynamic Metro fixture

* fix(test): preserve own-host restart fixtures

* fix(ci): preserve routed contracts after rebase

* Stabilize Playwright wheel-scroll assertions
2026-08-02 00:56:05 +08:00
Kevin Aspesiandpaseo-bot[bot] 429155b536 Add collapsed-project status badge to the sidebar (#2340)
* Add collapsed-project status badge to the sidebar

Collapsed project rows now surface the most urgent hidden workspace's
status as a corner badge on the project icon, instead of giving no
signal at all: a loader for running, an alert for needs_input, a dot
for attention/failed, nothing for done. Priority order (needs_input >
failed > running > attention > done) means a working project keeps
its loader even if another workspace is also awaiting review.

Every surfaced status shares one badge shell (size, ring, offset) so
the loader, alert, and dot never draw a differently-sized circle -
previously the loader/alert used a 12pt ring while the dot used a
7-9pt one with a different corner offset. The running loader is also
nudged half a point left so its visual center lands on the circle's
center rather than reading as shifted right.

Adds an e2e capture spec (run on demand, not part of CI) that seeds
every state and edge case through the mock provider and dumps DOM
geometry, for re-verifying the badge shell stays uniform.

* Add PR review gallery images for the status badge PR

Temporary evidence assets for pull request review, embedded via blob
URLs in the PR description. Safe to delete after merge.

* Swap misleading before/after PR images for clean per-state stills

This feature never shipped on main, so there is no real "before" to
compare against for a reviewer — the earlier before/after images were
an artifact of local dev iteration, not a regression fix. Replaced
with one clean still per status.

* Fatten needs-input alert and dot to fill the shared badge shell

Lucide's circle-alert only paints ~83% of its nominal size, so the
prior 10pt glyph read as smaller than the loader inside the same
14pt shell. Sized both up so all three glyphs draw the same visual
footprint against the shared ring.

* Address review: extract pure badge-content selection, drop JSDOM tests

Greptile flagged getStatusDotColorStyle's needs_input/running cases as
dead (unreachable — shouldRenderSyncedStatusLoader and an explicit
check already intercept those buckets earlier). Extracting the
selection logic into a plain-TS descriptor function
(getProjectStatusBadgeContent) makes that structural: the "dot"
variant is typed to failed|attention only, so there's no switch left
with unreachable branches.

That extraction also resolves the JSDOM component test Greptile
flagged (project-leading-visual.test.tsx used vi.hoisted/vi.mock/
createRoot mounting, all banned by docs/testing.md's two test
categories) — the pure selector is now tested directly with a plain
vitest file, no mounting needed. Dropped that test file and
use-sidebar-project-status-bucket.test.tsx (same banned pattern); the
real coverage was already redundant with sidebar-workspaces-view-model
.test.ts's existing priority-order assertions and the e2e capture
spec's real-app gating checks.

Also split a conditional branch in the e2e capture spec's assertion
loop into two deterministic loops (badge-expected vs no-badge cases)
per Greptile's third note.

* Size status-badge glyphs even so they center in the shell

The badge shell's interior is 12pt (14pt minus the 1pt ring on each
side). A centered glyph of size N sits at a (12 - N) / 2 offset, which
is fractional for odd N and gets snapped to a device-pixel boundary,
rendering visibly off-center (~1.5 device px at 3x). Drop the dot to 8pt
and the alert to 10pt so both divide the interior into whole pixels and
land dead center; Lucide's circle-alert paints ~83% of nominal, so 10pt
draws a ~8.3pt disc that matches the 8pt dot.

Also extends the on-demand capture spec to cover the failed bucket, the
priority aggregation cases, dark mode, hover, a compact viewport, and a
recorded running-to-expand transition, dumping DOM geometry that proves
every shell shares one size/offset and every even glyph centers at 0px.

* Refresh status-badge PR assets for the even-glyph sizing

Recaptures every state at the approved 8pt-dot / 10pt-alert sizing,
adds the failed bucket to the per-status set, a crosshair sizing/
centering proof, dark-mode and compact-form-factor shots, and replaces
the soft upscaled spinner GIF with a crisp one assembled from native
3x-scale frames. Drops the stale pre-sizing images.

* Add capture spec proving workspace statuses roll up to the project badge

Seeds three collapsed projects, each with several same-directory
workspaces in different buckets, so each resolves to a different
highest-priority winner (needs_input / failed / running). Films the
collapse and expand so the aggregation is provable as motion, not a
single frame.

* Add propagation recording showing statuses resolving to the project badge

* Add higher-quality mp4 of the status propagation recording

* Refactor project status badge coverage

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-02 00:37:22 +08:00
Matt CowgerandMatt Cowger 76abc50f82 Fix/local branch worktree base (#2328)
* Add automatic plus branch maintenance workflow

* Build plus artifacts for desktop and CLI/server

* Document and summarize plus artifact workflow

* Document Apple signing and pin plus workflow actions

* Disable Linux plus desktop artifact

* Publish plus CLI packages to GitHub Packages

* Fix GitHub Packages publish paths

* Fix plus mise install summary package name

* Disable macOS x64 plus desktop artifact

* Tag plus npm packages as latest for mise

* fix(worktrees): respect selected local base ref

* chore(ci): remove fork-only plus workflow

* fix(worktrees): preserve exact branch picker refs

---------

Co-authored-by: Matt Cowger <1929548+mcowger@users.noreply.github.com>
2026-08-01 16:06:56 +00:00
Mohamed Boudra b0a5c09ace Add shared auto-accept controls to ACP sessions (#2752)
* feat(acp): auto-accept shared permission requests

Some ACP agents expose permission requests without a native mode that can bypass them. Keep the opt-in at the shared adapter boundary so every ACP implementation gets the same per-session behavior.

* test(acp): include the shared feature in provider expectations

* fix(acp): honor unattended auto-accept intent

* fix(agent): list client features without a model
2026-08-01 23:55:06 +08:00
Kevin AspesiandMohamed Boudra 47c18a9213 Fork an agent session mid-run from the in-flight turn footer (#2638)
* feat(app): fork an agent session mid-run from the tab menu

Forking already existed, but only on the footer of a *completed*
assistant turn. While an agent is running, the in-flight turn renders
RunningTurnFooter with no fork menu, so exploring another path meant
scrolling back to hunt for an older turn's footer — or waiting for the
run to finish.

Add two entries to the agent tab context menu ("Fork chat in a new tab",
"Fork chat in a new workspace"), reachable regardless of run state. They
omit the fork boundary, and selectForkContextRows projects the whole
timeline when no boundary is given, so a mid-run fork carries everything
up to now — including the partially streamed in-flight turn.

Fork logic moves out of agent-stream/view.tsx into a shared useForkAgent
hook so the turn footer and the tab menu share one code path; the
per-turn behavior is unchanged (it still pins a boundary to its turn).

No server, protocol, or capability changes — this reuses the existing
agent.fork_context RPC and agentForkContext host feature. The daemon
handler is already a pure in-memory timeline read, so it was safe
mid-turn before this change.

* fix(app): resolve missing agent records before failing a fork

handleForkAgent read the agent record from the session store and gave up
if it was absent from both agents and agentDetails. A persisted agent tab
can outlive its record — restored on startup before hydration — so both
fork entries failed with a generic toast even though the agent exists on
the daemon and is perfectly forkable.

Fall back to client.fetchAgent and store the result before failing,
mirroring the resolution path agent-panel.tsx already uses. Only report
forkFailed when the agent genuinely cannot be resolved; a disconnected
host now says so instead.

The resolution logic lands in resolveForkAgentSource with injected
dependencies, since workspace-screen.tsx has no handler-level test
harness and a sibling testable module is the pattern in this directory.

storeFetchedAgentDetail moves out of panels/agent-panel.tsx into
utils/agent-detail-store.ts so the workspace screen doesn't import a
panel module — and into its own file rather than agent-directory-sync.ts,
which would close an import cycle through legacy-daemon-workspaces.

* feat(app): fork from the in-flight turn instead of the tab menu

Move the mid-run fork affordance to where the fork button already lives:
the assistant turn footer, beside the progress loader.

RunningTurnFooter previously showed only a spinner and elapsed timer, so
the fork button vanished for the whole duration of a run. It now renders
AssistantForkMenu alongside them, reachable the moment a run starts.

The in-flight fork passes no boundary, so selectForkContextRows projects
the whole timeline and the fork captures the response being streamed. A
fork button sitting next to a live response that silently dropped that
response would be worse than no button. Completed turns still pin their
own boundary, enforced by a separate handler type that keeps boundary
required.

layout.ts is untouched — the affordance is added to the running footer
component rather than by lifting the footer suppression, so no copy or
duration affordance leaks onto an incomplete turn.

Reverts the workspace tab context menu approach, along with the agent
resolution helper and store-writer extraction that existed only to serve
it. The footer path already holds the agent record.

* test(app): cover the in-flight turn fork with a Playwright spec

Extends the existing assistant fork menu spec rather than adding a
sibling file, so the completed-turn and in-flight cases sit side by side.

The new test pins the behavior that defines the feature: the in-flight
fork omits the boundary fields entirely, while the completed-turn fork
sends one. Both assertions read the actual agent.fork_context frames off
the WebSocket, using the read-only page.on("websocket") idiom from
helpers/timeline-delivery.ts so the observer cannot perturb the flow it
measures. Note the request omits the fields rather than nulling them —
daemon-client spreads them conditionally — so absence is what's asserted
there, and null only on the response.

Also asserts the fork trigger is scoped inside turn-working-indicator,
that no copy or duration affordance leaks onto the incomplete turn, that
the attachment carries the streamed text, and that the source agent keeps
streaming afterwards.

* test(app): cover the in-flight fork call site, gate, and teardown

Three behaviors this PR introduced had no coverage below the Playwright
spec. A copy-paste of resolveAssistantTurnForkBoundary into
handleForkInFlightTurn would have left every unit test green.

view-fork-in-flight.test.tsx mounts the real AgentStreamView with the
render strategy stubbed to the live-auxiliary slot, so handleForkInFlightTurn,
useStableEvent and the readOnly expression all stay real. It asserts the
fork request has no boundary KEY — matching the wire contract, where the
field is absent rather than null — for both targets, and that the read-only
gate withholds the trigger, with positive controls either side so an empty
tree cannot pass for a working gate.

turn-footer.test.tsx covers the race this PR created: the fork menu now
mounts only while a run is in flight, so a fork can be pending when the
subtree is torn down. The behavior is benign — React 18 makes the orphaned
setState a no-op — so the test asserts the observable outcome and leaves a
tripwire for unhandled rejections, since AssistantForkMenu's finally has no
catch.

Each test was verified by mutation: adding boundary: undefined, dropping the
readOnly gate, and keeping the running subtree alive each fail the matching
test.

Scoped to what this PR changed. The relocated hook's error paths, draft
setup construction, and attachment metadata predate it and stay untested
here.

* test(app): replace mocked fork coverage with Playwright

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-01 16:41:30 +02:00
e35ad9f692 feat(app): preview HTML files in the file pane (#2712)
* feat(app): preview HTML files in the file pane

Renders .html/.htm files as a page in the file pane, with the same
Preview/Source toggle Markdown already has. The main use is reading
richer visual plans an agent wrote as a self-contained HTML page.

A preview is a viewer, not a browser. Each document is prefixed with a
doctype and a strict policy, so the policy is always the first element
and always lands in the parser-created head: inline script and style
run, and remote subresources, fetch, XHR, WebSocket, beacon, and form
posts are refused. The frame has an opaque origin, so it cannot reach
the app's DOM and storage and cookie APIs throw inside it.

One gap is left on web and cannot be closed there: a sandboxed
document may navigate itself, and no CSP directive in current browsers
prevents it -- navigate-to was dropped from CSP3 and is unenforced, and
meta refresh needs no script (both verified against the Chromium the
app ships against). The opaque origin bounds it to the page's own
contents, and SECURITY.md documents it. Native narrows it further by
refusing navigation after the initial document, with the caveat that
the decision runs in app JS.

Self-contained pages only -- a page that pulls a CDN script renders
unstyled, and Source still shows everything.

* fix(app): harden HTML preview behavior and tests

---------

Co-authored-by: Nicholas Salgueiro <nicholas.salgueiro@britecore.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-01 14:30:59 +00:00
Mohamed Boudra 6d3d8068a4 fix(server): keep workspace titles task-shaped (#2755)
Generated titles optimized for topical brevity and could omit the requested operation or the identifier that distinguished the task. Preserve the operation, target, and strongest anchor so sidebar titles remain actionable.
2026-08-01 22:15:10 +08:00
Mohamed Boudra fb9f7fb98a Make the command center stable and workspace-aware (#2749)
* feat(app): expand command center actions

Keep command center navigation stable by revealing only clipped rows without smooth centering. Prioritize a concise default list while making workspace, Git, pane, and creation actions searchable through the existing contribution and action-policy paths.

* test(app): search for query-only command action

Add project is intentionally absent from the unfiltered command center. Exercise the overlay flow through search before selecting it.
2026-08-01 15:38:07 +02:00
Mohamed Boudra 47dcb64ac7 test(server): stabilize Hub worktree archive test (#2750)
The outer test deadline could expire before the archive observer's own timeout after slow Windows setup. Leave enough room for daemon and Git setup so the operation-specific failure remains authoritative.
2026-08-01 15:34:10 +02:00
Mohamed Boudra 8021d2b6c9 Keep agent timelines stable from submission through resume (#2718)
* Reapply: Stop completed turns from appearing stuck (#2484)

* Reapply: Keep older chat history and image previews stable (#2490)

* Lock timeline layout stability with user-driven specs

Two reds reproduce the reported jank:
- new agent, first prompt: the row paints at top=100 then jumps to 144 through
  authoritative hydration, because the draft tab and the agent panel each render
  their own AgentStreamView for the same logical row.
- long loaded timeline, submit: scrollHeight collapses 10793 -> 9111 mid-turn,
  because splitOrderedTail reclassifies measured rows into the virtualized segment
  where the virtualizer falls back to estimateStreamItemHeight.

Also restores centered timeline rows: the pagination anchor wrapper introduced in
#2490 was a plain block div, which broke alignSelf: center.

* Measure the turn footer blink from when the footer appears

Bounding the window at the prompt row conflated two things: the footer arriving a
frame after the row, and the footer leaving the layout and coming back. Bounded from
the footer's first frame, the blink still reproduces, so the footer-ownership race is
real rather than a sampling artifact.

* Rename the shadowed prompt row binding in the frame recorder

* fix(desktop): allow concurrent worktree dev instances

Desktop terminals can inherit another instance's profile path and debugging flags. Scope the profile to the current dev root and select an available debugging port so instances do not share global runtime state.

* fix(desktop): expose development providers in dev builds

Desktop-managed daemons inherited no Paseo runtime mode, so dev builds constructed the production provider registry. Mark unpackaged internal launches as development while continuing to force packaged launches to production.

* fix(timeline): stabilize optimistic turn layout

Keep initial history loading dormant until user intent and preserve active-turn layout across delayed transport updates. Add painted-frame coverage for optimistic timeline transitions.

* fix(timeline): preserve optimistic prompts through hydration

Canonical replacement relied on a transport submission record that can settle before a provider echoes the user row. Keep presentation continuity owned by the unreconciled row while destructive history replacement remains governed by active transactions.

* fix(timeline): make submitted prompts canonical

Submission settlement previously depended on status updates that could arrive after the optimistic row. Persist every accepted prompt in the canonical timeline and keep the footer active until RPC and canonical acknowledgement converge.

* fix(timeline): stop older history pages repeating entries

Backward pagination counted source sequence rows, so page boundaries inside a merged projected item could return that item repeatedly. Count projected entries and advance from their display anchors instead, with server and browser regressions covering the contract.

* fix(timeline): resume agents from the latest history

Use a bounded tail probe on resume so long background runs appear atomically instead of replaying every missed page. Preserve unchanged views, reconcile local/live rows, and keep skipped history reachable through backward pagination.

* fix(timeline): reconcile turn state after missed updates

Turn completion events can be missed while an agent timeline is unsubscribed, leaving footer activity stale even after the directory reports idle. Reconcile the single-owned turn lifecycle from authoritative resume snapshots while preserving newer live evidence.

* test(timeline): cover running state across refresh

Protect the hydration boundary where directory state must restore consistent tab, composer, and footer activity. Expose the running indicator semantically so the regression checks user-visible state instead of spinner implementation details.

* fix(timeline): keep history loading geometry stable

Reserve a fixed history-start gutter regardless of pagination state so showing or hiding the loader cannot shift timeline content.

* fix(timeline): keep turn activity coherent

Derive footer, tab, and composer activity from one lifecycle and submission projection. Idle directory transitions now request an authoritative resume so missed terminal events converge without treating directory delivery order as turn authority.

* fix(tests): align checks with runtime boundaries

* fix(desktop): stop dev app surviving its terminal

Electron was detached from the desktop runner, allowing it to outlive a stopped workspace terminal with broken output pipes and retain the single-instance lock. Keep Electron owned by the runner and preserve explicit process-group cleanup for detached child trees.

* fix(timeline): preserve activity through turn handoff

Correlate lifecycle events and snapshots with daemon-owned turn identity, and keep optimistic submissions active until their canonical row arrives. This prevents stale updates and RPC timing from exposing a false idle state.

* refactor(timeline): remove submission timing state

* fix(timeline): restore prompt identity on resume

* fix(timeline): exclude attachments from canonical prompt text

Structured provider prompts can include hidden history attachments before the user's text. Persist only plain prompt blocks so authoritative user messages match what the composer displayed while provider input remains unchanged.

* refactor(agent): remove unused pending-run message identity

* fix(desktop): let Chromium allocate the debugging port

* refactor(agent): remove impossible finalized-turn tracking

* fix(timeline): load history after keyboard scrolling

Treat observed upward displacement as user intent so keyboard and scrollbar navigation can wake dormant pagination.

* test(server): preserve accepted turn stream order

The manager-owned accepted turn boundary intentionally precedes coalesced output. Assert that lifecycle event explicitly while retaining the timeline-before-terminal ordering contract.

* test(app): scope sidebar fixtures by identity

Shared E2E daemon state may contain unrelated projects. Keep stale-route coverage tied to its named missing project and drag only the two rows seeded by the reorder spec.

* fix(timeline): gate prompt revisions by client capability

* fix(timeline): finalize canonical prompt delivery

Treat provider identity as a single stored-row transition so legacy clients receive one deliverable canonical prompt. Release identity-pending rows before later foreground output or the current terminal while preserving stale-turn isolation.

* fix(server): preserve legacy prompt pagination

* fix(timeline): keep assistant block identity stable

Assign canonical block IDs during live promotion so terminal finalization cannot remount image-bearing rows.

* fix(timeline): apply covered revisions during resume append

Run the exact canonical revision pass before overlapping resume pages append newer rows, while leaving cursor advancement to the incremental path.

* fix(server): preserve queued agent transitions

Project each queued live snapshot before awaiting stored metadata so later lifecycle state cannot overwrite an earlier transition.

* fix(server): snapshot queued agent updates

Capture each live agent payload before it enters the per-agent queue so shared nested state cannot change while an earlier storage read is pending.

* docs(app): date agent turn compatibility cleanup

* refactor(timeline): mint submitted prompt identity at acceptance

Canonical prompt rows now keep one immutable wire identity while provider identity remains internal for rewind translation. This removes the unreleased same-sequence revision and legacy projection contract.

* refactor(app): remove prompt revision reconciliation

Submitted prompt rows are immutable on the wire, so resume and live reducers no longer need to apply already-covered same-sequence updates.

* test(app): cover submitted prompt rewind roundtrip

* docs(timeline): describe immutable submitted prompt identity

Provider echoes now enrich daemon-internal identity only; wire rows keep their acceptance-time identity and rewind performs the provider translation.

* test(server): validate mock rewind targets

Reject unknown provider message IDs so app rewind coverage exercises wire-to-provider identity translation.

* fix(server): validate rewind before interrupting

* fix(server): omit rewind identity for daemon prompts

* test(app): guard rewind eligibility and rejection

* fix(server): preserve prompt identity across hydration

* refactor(timeline): remove stale revision remnants

* Revert "fix(server): preserve prompt identity across hydration"

This reverts commit 6adcf7e5b0.

* fix(timeline): settle missed submission acknowledgements
2026-08-01 14:13:47 +02:00
Mohamed Boudra 8b87299ce1 Update PULL_REQUEST_TEMPLATE to enhance Non-Goals section
Clarified the Non-Goals section to include intentional trade-offs.
2026-08-01 19:19:28 +08:00
Mohamed Boudra 014b20e5b2 Update PULL_REQUEST_TEMPLATE.md 2026-08-01 19:06:40 +08:00
Mohamed Boudra ef63dd212b Keep restored merged workspaces active (#2714)
* fix(workspaces): keep restored merged workspaces active

Auto-archive previously reacted to every merged pull request snapshot, so restoring a workspace immediately archived it again. Persist the consumed change request with the archive transition so explicit recovery remains authoritative.

* test(e2e): clean partial archive scenarios

Track workspace resources as soon as creation succeeds so later setup failures archive the worktree and remove the registered project.

* test(workspaces): pin archive latch restoration

Cover the persisted archive marker across restore in CI and make the real browser assertion observe active workspace stability after refresh.

* fix(workspaces): latch restored legacy archives

Resolve the current merged change request while restoring records created before the archive marker existed, then persist it with the unarchive mutation.

* fix(workspaces): refresh latch on restore

When an archived workspace now points at a different merged change request, record that URL during restore so the restored workspace remains active.

* test(e2e): make archive latch setup fail loudly

Remove authentication-gated skipping and register each external fixture cleanup immediately after allocation so setup failures cannot leak resources.

* test(workspaces): include archive latch in reconciliation
2026-08-01 12:42:18 +02:00
Mohamed Boudra a059af1eda fix(app): preserve dictation text during submit (#2745)
Dictation auto-send bypassed the controlled composer update, so preserved draft surfaces could lock while showing an empty prompt. Publish the transcript before invoking the existing submit lifecycle so pending and failed submissions retain the user's spoken input.
2026-08-01 12:28:55 +02:00
Mohamed Boudra 6177d85e99 Make sidebar shortcuts leave focus mode (#2717)
* fix(app): exit focus mode for sidebar shortcuts

Focus mode kept sidebar actions hidden after their handlers ran. Exit only after a sidebar action is handled so the requested action still takes effect.

* fix(app): order focus exit before sidebar action

Leave focus mode before dispatching the sidebar command so its handler runs against the visible panel state.

* fix(app): preserve focus mode outside workspaces

Only exit active workspace focus mode after a sidebar shortcut is handled. Keep persisted focus state unchanged when the shortcut has no foreground workspace target.
2026-08-01 11:16:48 +02:00
Mohamed Boudra 70ed70d36e ci(nix): build the desktop package on macOS 2026-07-31 23:40:28 +00:00
Ivan Malison a8637d4bbc Skip code signing for the Darwin Nix package 2026-07-31 23:38:46 +00:00
Ivan Malison 7d4100a021 Stage writable Electron distribution on Darwin 2026-07-31 23:38:46 +00:00
Ivan Malison 6e74c924e6 Package the Darwin desktop with electron-builder 2026-07-31 23:38:46 +00:00
Ivan Malison b151dfb88d Add Darwin desktop package to flake 2026-07-31 23:37:59 +00:00
Ivan Malison b960594d44 fix(nix): shrink desktop runtime output (#2550)
Package only the traced desktop and daemon runtime graph, preserve required runtime entrypoints, and exclude the npm Electron distribution supplied by nixpkgs.
2026-08-01 01:37:18 +02:00
Mohamed Boudra b560c1184c test(server): wait for terminal env marker contents (#2715)
The Windows runner could observe the marker after creation but before its contents were written. Wait for the expected value so the test synchronizes on the behavior it asserts.
2026-08-01 00:35:27 +02:00
Ivan Malison 5f575fcdd6 fix(dev): use portable Bash shebangs (#2536)
Resolve Bash through PATH so repository development entrypoints run on NixOS and other systems without /bin/bash.
2026-08-01 00:14:39 +02:00
Ivan Malison c0dcb4433a fix(nix): keep docs and CI out of derivation sources (#2652)
Exclude non-build repository content from the daemon and desktop derivation inputs while preserving nested runtime Markdown such as skill definitions.
2026-08-01 00:03:50 +02:00
Shintaro Sakata a54cb66bf6 fix(nix): keep runtime mode out of agent environment (#2697) 2026-07-31 23:29:02 +02:00
Mohamed Boudra 9d19e90323 Show worktree names in workspace hover cards (#2711)
* fix(app): show worktree names in workspace hover cards

Managed worktree paths exposed Paseo's internal directory layout instead of the useful worktree name. Carry the owned worktree root through the sidebar model while preserving the full workspace path as the copy target.

* fix(workspaces): source owned worktree names from daemon

The daemon owns worktree path semantics, so expose the managed worktree slug and let the sidebar model produce a display-ready label. This keeps path parsing out of the hover-card component while preserving the full path for copying.

* fix(app): preserve worktree labels in replica cache
2026-08-01 05:17:47 +08:00
Mohamed Boudra d2de309fc3 Make relay opt-in when pairing devices (#2706)
* feat: make relay opt-in for device pairing

* fix(relay): keep pairing state authoritative

* fix(relay): refresh pairing state across transports

* fix(onboard): honor explicit relay opt-out

* fix(relay): surface transport startup failures

* fix(pairing): gate offers for older daemons

* test(cli): rely on real pairing transport coverage

* fix(cli): report live relay without a pid file

* fix(relay): synchronize config during daemon startup

* fix(relay): preserve mutability for ignored overrides

* fix(cli): use reachable daemon for pairing

* fix(app): redirect invalid pair device routes

* fix(pairing): support remote host device pairing

Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals.

* fix(pairing): validate pairing destinations

Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state.

* fix(pairing): render QR codes across platforms

Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack.

* fix(cli): pair from persisted relay state

Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
2026-08-01 04:35:06 +08:00
Mohamed Boudra 3778d0ad7b Fix mobile sidebar swipe and drag failures (#2709)
* fix(app): prevent text selection during Android swipes

Gesture interception did not cancel the native view where the touch began, leaving Android's pending text-selection action alive.

* fix(app): restore iOS sidebar close swipes

Fabric could fail to attach close handlers when their target lived inside retained but display-hidden panel content. Keep a registered native gesture host mounted outside the presentation wrapper so close swipes work over nested lists without activating rows.

* fix(app): recover sidebar drags after cancellation

Cancelled native drags could leave a lifted row and the shared outer list locked. Scope terminal cleanup to the owning gesture session and release the lock on cancellation or unmount.

* test(app): clean up partial drag fixtures
2026-08-01 04:05:54 +08:00
paseo-ai[bot] eb1c7def54 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-31 19:35:37 +00:00
Mohamed Boudra 79fccb368d chore(app): ship mobile updates through stores only (#2708)
Paseo does not publish EAS updates, so remove the update runtime and channel metadata from native builds.
2026-08-01 03:28:26 +08:00
Mohamed Boudra d7ced4a77e test(server): avoid commit history fixture timeout (#2707)
The bulk-history cases spawned about 80 Git processes, which could exhaust the per-test timeout on loaded Windows runners. Import each linear history in one Git process while preserving the real repository behavior under test.
2026-07-31 20:25:05 +02:00
nikuscsandMohamed Boudra 800bd2bef1 feat: add host-local custom project icons (#2335)
* feat(projects): add local icons and colors

* refactor(projects): simplify appearance handling

* fix(projects): serialize appearance saves

* test(app): update project appearance expectations

* test: fix project appearance CI expectations

* refactor(projects): organize appearance code

* fix(server): adopt main's reconcile model in project appearance updates

* fix(projects): preserve host-local appearance targets

* fix(desktop): restore Linux development startup

Ubuntu can block Electron's unprivileged user-namespace fallback while the npm-installed sandbox helper is not SUID. Disable the Chromium process sandbox only in the Linux development runner so packaged desktop policy remains unchanged.

* feat(projects): add custom project icons

Keep URL acquisition in the client so the host daemon only validates uploaded bytes. Project name and icon changes share one adaptive edit sheet.

* refactor(settings): scope projects by host

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-01 01:34:42 +08:00
706ae3d9bb Read Claude subagents from the SDK task protocol (#2498)
* refactor(claude): derive subagents from the SDK task protocol

Claude Code announces subagent identity and lifecycle on the SDK stream
(task_started, task_updated, task_notification). Paseo consumed only
task_notification and reconstructed the rest from sidechain frames, in two
independent derivations that had drifted apart.

Both paths now translate into one small observation vocabulary and fold it
with one function, so identity and status are derived once. The fold is
stateless: the task protocol is edge-triggered and the store's sticky merge
already accumulates.

- Replay links a subagent through the agent-<id>.meta.json sidecar, which
  carries the same Task tool_use id the live stream keys on. The agentId
  text scrape stays as a fallback for sessions recorded without it.
- The sidechain tracker keeps mapping frames to timeline items but no longer
  owns the descriptor when the task protocol is active, detected from the
  session rather than from a version string.
- Adds replay coverage, which did not exist.

Subagent ids are unchanged, so no protocol, store, or client change.

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

* fix(claude): keep non-subagent tasks out of the track, open the child timeline with its prompt

Backgrounded shell commands announce as task_started with a tool_use_id but
task_type local_bash and no subagent_type, so filtering on the id alone put
`sleep 20` in the subagents track. Discriminate on task_type, falling back to
subagent_type for releases that predate it.

task_started also carries the prompt. Emitting it as the child's first timeline
item means the pane no longer opens mid-conversation, and matches replay, where
the sidechain transcript already begins with the prompt.

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

* fix(claude): drop status for tasks that were never declared as subagents

task_notification also fires for backgrounded shell commands and carries a
tool_use_id. Routing status off that id readmitted exactly what the declaration
filter rejected, as a descriptor with a status and no identity — rendered in the
track as a nameless "Loading..." row.

Status now routes only through the task_id map populated at declaration.

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

* fix(app): name a subagent row after its task, not its type

The descriptor carries the subagent type in title and the task in description.
The client collapsed them and rendered only the type, so every child in a
fan-out read "general-purpose". The task is what tells siblings apart, so it
names the row and the type moves to the muted secondary line, which the track
computed but never drew.

Providers that report no task keep the previous behavior.

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

* fix(claude): label the parent's Task card from the subagent declaration

A backgrounded Task subagent emits no frames carrying parent_tool_use_id, so
the sidechain tracker never runs for one and never supplies the sub_agent
detail. Its card in the parent transcript rendered as a bare "Task".

The declaration already carries the type and the task, so the card is built
from it. For a foreground subagent the tracker still emits the same card
enriched with the child's action log; the two collapse on the shared tool-call
id, so that case is unchanged.

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

* feat(subagents): surface a subagent's model, effort, and cost

Adds model, effort and usage to the provider subagent descriptor, populated
from observations the child itself reports.

- model: read per assistant frame, live and on replay. Never snapshotted, since
  a provider can swap models mid-flight on overload or refusal fallback, and
  never inherited from the parent.
- effort: replay only. The SDK's assistant message does not carry it and it can
  be silently downgraded for the chosen model, so a live value would be a guess.
- usage: task_progress reports tokens and tool count once per tool use, and
  task_notification reports the terminal totals. This matters most for
  backgrounded subagents, which emit no sidechain frames and would otherwise
  show no sign of activity at all.

Status becomes optional on the upsert event: a report that only carries a model
or a cost says nothing about whether the child is still running, and must not
revert a finished one. Usage merges rather than replaces, because duration only
arrives at the end.

Fields are optional on the wire, so an older daemon simply omits them and the
client renders nothing rather than substituting the parent's settings.

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

* feat(claude): read subagent effort from hooks

Effort appears nowhere on the SDK message stream — verified by scanning every
message type at depth — and the level Paseo requests is not necessarily the one
that runs, since a model that does not support it is silently downgraded.

Hooks are the source. Every hook input carries agent_id and effort.level, the
active level for that turn after any downgrade, and agent_id is the same id
task_started calls task_id, so it routes through the table already built at
declaration. PreToolUse, PostToolUse and SubagentStop are registered purely to
observe; they record what they see and always return empty, so they cannot
affect tool execution or turn control.

Paseo's hooks merge with any the user configured rather than replacing them:
extraClaudeOptions is spread after the base options, so assigning would have
silently stopped effort being observed for anyone with their own hooks.

Verified end to end through ClaudeAgentClient against a real session: a
subagent descriptor now carries model, effort and usage together.

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

* feat(subagents): show a subagent's runtime facts in its pane, and its cost on replay

The track row carried model, effort and cost; the pane carried none of it, so
opening a subagent showed only its task.

The pane gets a header strip rather than a longer tab subtitle: the subtitle
feeds the width-constrained tab strip, whose job is to name the task. Duration
appears in the pane, which has room, and stays out of the dense row.

Replay had no usage source, so a reopened agent lost its subagents' token and
tool counts permanently — descriptors are in-memory and cleared when an agent
closes. Usage is now derived from the child's own transcript, matching the live
producer exactly: total tokens are the four counters summed on the last
assistant turn (a context reading, not a cumulative sum, which would multiply
the cached prefix by the turn count), and tool uses are tool_use blocks across
assistant entries. Duration is an approximation — the transcript's first entry
is written after the task starts — and is the one field replay cannot match.

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

* docs(claude): record the task protocol's undocumented wire facts

The subagent sources depend on Claude Code internals the SDK types do not
explain: which announced tasks are subagents, why a status for an undeclared
task is dropped, why task ids outlive a turn, why effort is only reachable
through hooks, and why a replayed session must skip its grandchildren.

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

* Generalize provider subagent presentation

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-31 17:22:44 +00:00
Mohamed Boudra de44c8cd66 Show New Workspace isolation controls immediately (#2702)
* fix(workspaces): show isolation without checkout delay

* fix(workspaces): trust hydrated project capability

* fix(workspaces): resolve branch before creation

* fix(workspaces): honor invalidated checkout status

* fix(workspaces): preserve isolation while projects load

* fix(workspaces): keep provisional project routes usable

Route-derived projects do not know their worktree capability. Represent that uncertainty at the placement boundary so remembered worktree intent survives hydration without disabling the New Workspace screen indefinitely.
2026-07-31 17:50:35 +02:00
35f985baff Switch reasoning, mode, plan, and fast from the Command Center (#2274)
* Switch reasoning, mode, plan, and fast from the Command Center

Extend the Command Center (Cmd-K) with selectors for a focused agent's
Thinking (reasoning effort), access Mode, Plan mode, and Fast mode,
mirroring the model switcher. Typing surfaces tiered "<Setting> › <Value>"
breadcrumb rows with icons and a check on the current value, for both a
running agent and a new draft tab. Rows only appear while typing, so the
default palette is unchanged.

- Thinking and Mode are multi-option selects; Plan mode and Fast mode are
  On/Off toggles.
- Plan mode is dual-natured: a permission mode for Claude/Copilot/OpenCode
  (excluded from the Mode list, given its own toggle) and the plan_mode
  feature for Codex. Fast mode drives the fast_mode feature (model-gated).
- All logic lives in command-center/agent-setting-contributions.ts as pure,
  unit-tested builders, with a small icon helper and a hook that registers
  the running-agent selectors; the draft registers from its composer tab.
  No changes to the command palette component and no protocol changes.

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

* Unify Command Center agent controls

* refactor: clarify agent control ownership

* refactor(app): tighten command center agent controls

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-31 17:37:04 +02:00
Mohamed Boudra 1ddca07d57 Show the current pull request after switching branches (#2699)
* fix(git): reconcile pull requests after branch changes

Treat persisted change-request metadata as a branch-bound hint and prefer current tracking configuration when it identifies the checked-out branch.

* fix(git): preserve enterprise fork identity

Use branch-bound metadata to supplement the owner when explicit tracking points at the same head on a self-hosted remote.

* fix(git): honor local branch upstreams

Treat Git remote dot with a merge ref as explicit current tracking so stale PR metadata cannot override a local upstream.

* fix(git): own change request branch bindings

Centralize persisted hint validation and rebinding so renames preserve change request identity while switches invalidate it, including normalized legacy Enterprise owners.

* fix(git): preserve PR identity through workspace renames

Resolve the backing worktree root during rename without adding a Git call, and retain fork owner identity from explicit same-name tracking configuration.

* fix(git): trust current enterprise fork identity

Explicit tracking now derives repository identity from the current remote path, so stale checkout metadata cannot override a repointed fork.

* fix(git): recognize enterprise repository aliases

GitHub repository identity is case-insensitive. Normalize equality checks so differently cased aliases cannot turn base tracking into a fork lookup.

* fix(git): reject ownerless legacy fork matches

Owner-qualified legacy hints only belong to their historical owner-prefixed branch names. A later ownerless branch reusing the same head must resolve from current Git state instead.

* fix(git): keep fork feature lookups local

A feature branch may track the upstream base branch for ancestry even when origin is a fork. Only treat that cross-repository base ref as PR identity when the local branch name identifies the tracked head.
2026-07-31 23:06:10 +08:00
Mohamed Boudra baa1c08ec8 Update SKILL.md by removing models section
Removed models section from SKILL.md and added clarification on provider resolution.
2026-07-31 21:56:11 +08:00
Mohamed Boudra 99eca19a76 Keep selected projects when switching hosts (#2700)
* fix(app): preserve project selection across hosts

Rendered project identity and persisted equivalence were conflated after duplicate-project handling. Keep them separate so host changes resolve selectable equivalents deterministically without resetting legacy sidebar identity.

* fix(app): restore selected clone after host round trip

Keep manual origin and current destination placements separately so switching back restores the chosen checkout while archive gaps preserve the active host selection.

* fix(app): key status icons by project view

* fix(app): preserve origin during archive gaps
2026-07-31 15:42:08 +02:00
nikuscsandMohamed Boudra 87ef631ac4 Show project icons in status-grouped sidebar rows (#2416)
* feat: show project icons in sidebar status view

* test(sidebar): keep status icon regression active

The ten-second mock can finish during app startup, removing the Working bucket before the icon assertion. Keep the seeded workspace active for the full scenario.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-31 14:44:31 +02:00
Mohamed BoudraandDes Flynn d9b72e1c76 Use OpenCode busy status for background activity (#2696)
* fix(server): ignore OpenCode snapshot diffs when starting autonomous turns

OpenCode stamps its filesystem-snapshot diff onto the user message after
the turn goes idle. Since #2258 Paseo treats an unknown idle user
message.updated as the start of an autonomous turn, so that late metadata
write opened a turn no provider-idle boundary would ever close. The agent
stayed falsely running until the next submission interrupted it.

Reject user message.updated events carrying summary.diffs. Real prompts
never carry snapshot diffs, so genuine autonomous turns still start, and
filesystem snapshots stay enabled for undo and revert.

* fix(server): trust OpenCode busy status for autonomous activity

Mutable message records can receive metadata after execution ends. Treat the provider's busy status as the activity boundary and buffer preceding content until that boundary arrives.

* test(server): cover autonomous provider activity

* fix(server): reconcile OpenCode wakes from busy status

# Conflicts:
#	packages/server/src/server/agent/providers/opencode-agent.ts

* fix(server): preserve background activity boundary after stops

* fix(server): keep idle events outside background activity

Only provider execution status proves a new activity boundary. Reconcile adopted sessions once at subscription time instead of retaining unowned idle events for a future run.

* fix(server): order adopted session reconciliation

Live runner-status events supersede an in-flight status snapshot. Treat busy and retry as one active provider state.

* test(server): remove misleading snapshot gate

---------

Co-authored-by: Des Flynn <des@desflynn.com>
2026-07-31 13:58:08 +02:00
Mohamed Boudra 8e983c5d27 Stop transient file conflict callouts (#2694)
* fix(app): prevent transient file conflict callouts

Watcher versions are candidates until a matching disk read settles them. The editor now classifies the settled bytes once, preserving dirty buffers and keeping reload content paired with its revision.

* fix(app): preserve external BOM changes
2026-07-31 19:03:11 +08:00
Mohamed BoudraandClaude Opus 5 91da0bac4d Choose which orchestration skills get installed (#2680)
* feat(settings): choose which orchestration skills to install

The skills row installed the whole bundle or nothing. A settings control
next to it now opens a sheet where you keep every bundled skill or pick a
subset, and saving converges ~/.agents, ~/.claude, and ~/.codex to it.

"All skills" reads the bundle directory instead of a hardcoded catalog, so
a skill added in a later release installs itself without another code
change. Retired bundle names stay in a separate cleanup list so old copies
on disk are still removed but never become selectable.

The selection persists under Electron user data, separate from install
state, so uninstalling does not erase the choice — a later install brings
back exactly the chosen set. Installs with no saved selection read as
"all", which is what they already had.

* Restore skill selection after failed save

* fix(settings): don't save a skill selection that never reached disk

A failed save wrote the preference, converged, then wrote the old preference
back. That leaves a window where a selection the user was told did not save
is the one on disk, and a crash inside it makes the window permanent.
Converge first and persist only on success — nothing to roll back, and a
failed save now leaves disk and preference exactly as Cancel would.

Resolving the bundle path when the command handlers are wired also broke
daemon-manager's tests: the packaged branch reads process.resourcesPath,
which is undefined outside Electron. Resolve targets per command instead —
registering handlers should not touch the environment.

* fix(settings): make skills convergence single-writer and confirm removals

Startup auto-update and the settings commands were separate writers over the
same skill directories. Either could apply a plan computed from a selection the
other had already replaced — narrow the selection while startup is repairing
drift and the repair puts the removed skill back. There is now one controller
that owns scan, apply, and persist, and every entry point queues through it, so
concurrent callers see a consistent result instead of an interleaved one.

Save also had no recovery once it had converged: if the selection could not be
committed afterwards, disk was changed while the preference still said the old
thing, and the next startup silently undid the change. Save now puts the
managed directories back to what the committed selection describes before it
reports the failure, so a failed Save lands where Cancel would.

Deselecting a skill deletes its directory in all three agent homes, including
files the user put inside it. That is now confirmed like every other
destructive action, naming the skills and the directories. Adding skills or
saving an unchanged selection still saves without a prompt, and a skill the
host has not installed yet deletes nothing so it does not prompt either.

* fix(settings): confirm removal for skills installed in only some agent homes

The removal confirmation read an `add` op as "not on disk yet". An `add` op
means "missing from at least one target", so a skill sitting in ~/.claude but
not the other two looked absent, and deselecting it deleted that directory —
with anything the user had put inside it — without asking.

The status snapshot now carries the installed skill names directly, taken from
union presence across the three agent homes, and the confirmation reads that.
`ops` keeps its one meaning.

A host that predates the field cannot say what is on disk, so the app bridge
assumes the saved selection is installed. Asking about a directory that turns
out to be absent is harmless; the reverse is not.

* fix(settings): warn about every skill directory a save deletes

The confirmation asked "was this selected before and is it selected now",
which is a different question from the one the host answers. The host
converges to the desired set and deletes every managed directory outside it,
so a skill on disk that the saved preference never mentioned — reinstalled by
hand, restored by a sync, left behind by drift — was deleted with no warning,
user files included.

The predicate now mirrors the host rule: everything installed that the draft
will not keep, regardless of what was saved before. It moved out of the sheet
into its own module so the rule has one owner and can be tested directly.
Saving with nothing to delete still does not prompt.

* fix(settings): a failed save keeps files the user added inside a skill

Save converges before it commits, and convergence deletes whole skill
directories. When the commit then failed, the recovery re-installed the
committed selection — which rebuilds the bundled files and nothing else, so
notes, hooks, and anything else the user had put inside a deselected skill
were gone for good. Cancel does not do that, and a failed save is supposed to
land where Cancel would.

Save now runs as a transaction. Before converging it stages a copy of every
managed skill directory across the three agent homes, next to the tree so the
copy stays on one filesystem. Committing drops the staging; failing anywhere
puts each directory back exactly as it was, deletes the ones the candidate had
created, and then drops the staging. Staging left by a process that died
mid-save is cleaned up by the next one.

The controller only sees begin/commit/rollback; the filesystem work lives in
skills-transaction.ts.

* fix(settings): make skill removal confirmation authoritative

* fix(settings): verify skill convergence before commit

* fix(settings): preserve concurrent skill edits on rollback

* fix(settings): preserve staged skill metadata

* fix(settings): stage skill deletions atomically

* fix(settings): stage removals per filesystem

* fix(settings): restore staged skill symlinks

* fix(settings): canonicalize skill transaction roots

* fix(settings): keep automatic skill updates non-destructive

* fix(settings): report committed skill saves accurately

* fix(settings): keep skill updates non-destructive

* fix(settings): route skill removals through selection

* fix(settings): close skill selection edge cases

* fix(settings): preserve skill data during maintenance

* fix(settings): hide unavailable skill install

* fix(desktop): keep install policy at command boundary

* fix(desktop): restore staged skill directories atomically

* test(app): retry transient skills sandbox cleanup

* fix(desktop): recover externally deleted skill stages

* fix(desktop): preserve conflicting skill rollback data

* fix(desktop): generalize skill rollback conflicts

* fix(desktop): preserve skill rollback collisions

* fix(desktop): recover quarantined skill backups

* fix(app): inset adaptive sheet content on compact

The compact Choose skills sheet rendered edge to edge: the All skills card
touched both screen edges, the section label was glued to it, and the skill
list ran to the edges too. Desktop was correct.

AdaptiveModalSheet expressed its content inset through
BottomSheetScrollView.contentContainerStyle. That is a library prop, not the
style prop Unistyles registers, and gorhom's scroller was never rewritten by
the Babel plugin, so on web the emitted class was never applied and the
padding and gap resolved to nothing. The desktop card uses a real ScrollView,
which Unistyles does track, which is why only compact was affected.

The inset now lives on a View the sheet owns, rendered by every presentation,
so one definition covers compact and desktop and a scroller only ever gets
theme-free layout. The caller-facing prop is contentStyle, since it no longer
lands on a scroll content container.

Choose skills drops its ad-hoc label for SettingsSection, so the label carries
the app's section rhythm instead of sitting equidistant between two cards.

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

* fix(desktop): prevent skill rollback data loss

Preserve externally replaced directory trees during recovery and reject managed child symlinks before synchronization can write outside a skill directory.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:28:32 +02:00
Mohamed Boudra f3fee73976 Stop OpenCode cancels from killing the next turn (#2662)
* fix(opencode): prevent late aborts from canceling replacement turns

OpenCode aborts are session-scoped, so provider idle alone does not make a session safe to reuse. Keep abort retries owned by the stopping turn and require both abort settlement and provider idle before dispatching the next runner operation.

* fix(opencode): retain the stop barrier through provider idle

Keep the stopping operation authoritative until both its session-scoped abort and provider-idle evidence settle, including stale idle-session interrupts. Also keep the real drift fixture credential-allowlisted and wait for command output pipes to drain.

* test(opencode): launch Windows fixture shims safely

* fix(opencode): recover stop observation safely

* fix(opencode): preserve autonomous wake through stop

* fix(opencode): retain deferred wake completion

* fix(opencode): serialize stop boundary replay

* test(opencode): use shared real e2e lane

* fix(opencode): own stops started during replay

* fix(opencode): retry stop observation on demand

* fix(opencode): follow replacement stop boundaries

* fix(opencode): retain wakes during stop recovery

* fix(opencode): hold replay turns behind boundary

* fix(opencode): filter canceled reconnect output

* fix(opencode): bind status evidence to stop

* fix(opencode): acknowledge owned cancellation

* fix(opencode): reconcile stale busy evidence

* fix(opencode): bind replay terminals to wakes

* refactor(opencode): own the stop boundary in one explicit state

The run boundary had grown into a mutable flag bag plus a raw-event
buffer that was replayed and re-classified after the fact. Each new
interleaving needed another predicate, and the classification kept
losing autonomous wakes.

Restore stopping as a turn-state variant that owns exactly two things:
the session-scoped abort it issued (with its single owned retry) and the
canceled run's terminal. Runner operations wait on both; a failed abort
fails closed. Provider status reconciliation now ends the stop through
the same transition an SSE terminal takes.

The canceled run's residue is suppressed only up to that terminal.
Events published afterwards are a new run by construction and take the
normal live path, so a wake no longer waits for abort settlement and
cannot be dropped by a retention heuristic.

* fix(opencode): emit cancellation before adopting the next run

The acknowledgement ran off a promise combinator, so a wake adopted from
the event right after the terminal could reach subscribers before the
canceled turn's turn_canceled. Acknowledge synchronously from whichever
proof lands first instead.

* docs(opencode): attach the stop doc comment to its interface

* fix(opencode): retry the stop in progress instead of opening a second

Pressing Stop again after an abort failed built a detached boundary: its
fresh abort could succeed while the canceled turn kept waiting for an
acknowledgement, so the daemon sat out the full cancel timeout before
force-canceling. A repeated Stop now replaces the abort owned by the
stop already in progress, which is what carries the acknowledgement and
what lifts the fail-closed gate.

* fix(opencode): accumulate every abort a stop has issued

A repeated Stop replaced the stop's abort settlement instead of adding
to it, so a first request still in flight — the slow-teardown case the
cancel cap exists for — no longer held the gate. The replacement could
start while that request was live and get cancelled by it, which is the
race this boundary exists to close. Reuse now waits for every abort the
stop issued, while only the newest can hold the gate closed.

* fix(opencode): scope abort settlement to the session, not the stop

Abort is session-scoped, so its settlement has to be too. Tracking it on
the stop meant a new stop boundary silently dropped an older request
that was still in flight, and that request could still cancel whatever
run started next. The session now accumulates every abort it has issued;
the stop is left owning only the run it is stopping.

* fix(opencode): recheck the abort gate after waiting on it

A waiting prompt read the settlement once, so a Stop issued while it
waited was invisible to it: the older abort would settle, the prompt
would start, and the newer one could still cancel it. Drain until the
settlement observed is the current one.
2026-07-31 12:08:43 +02:00
Mohamed Boudra eb5e8ba7c5 Revise PR template to highlight CONTRIBUTING.md
Updated the PR template to emphasize reading the CONTRIBUTING.md file.
2026-07-31 16:40:49 +08:00
Mohamed Boudra fe337b888d Update pull request template for type of change options 2026-07-31 16:40:18 +08:00
Mohamed Boudra 774b417cc5 Revise PR template for clarity and structure
Updated the pull request template to clarify guidelines and checklist items.
2026-07-31 16:39:41 +08:00
Mohamed Boudra b6f1274f47 Clarify changed and deleted files in the file viewer (#2670)
* fix(app): distinguish changed and deleted files

Successful reads now reconcile transient missing events without letting stale
cached data mask a deletion. File notices expose only actions valid for the
current buffer state.

* fix(app): preserve file state ordering

* fix(app): allow retrying file checks

* fix(app): clear unchanged file conflicts

* fix(app): keep file conflict recovery consistent

Own disk observation and read ordering in one model so stale reads and failed subscriptions cannot produce contradictory actions.

* fix(app): allow retrying file preview errors

Preserve daemon read errors and expose retry for initial and non-editable file loads.
2026-07-30 22:50:58 +02:00
Mohamed Boudra 240d7f366d Clarify discussion links for feature requests
Updated the 'about' sections for Discord and GitHub discussion links to clarify that they include feature requests.
2026-07-31 02:19:25 +08:00
Mohamed Boudra 710851ccb8 docs(contributing): allow draft PRs for checks and early feedback 2026-07-30 16:31:51 +02:00
Mohamed Boudra b18ad74ac6 docs: simplify contributing guide 2026-07-30 15:58:08 +02:00
Mohamed Boudra 588fdc794b docs: rewrite contributing guide, add QA and compatibility docs
Issues are bug-only now. The feature-request template is gone, requests
opened as issues get closed automatically, and feature talk moves to
product discussions on GitHub Discussions and Discord. "Product
discussion" is the term everywhere, recorded in the glossary.

Contributing states the QA evidence bar and links out instead of
teaching; docs/qa.md carries the depth. The protocol back-compat rules
moved out of CLAUDE.md into docs/protocol-compatibility.md, since that
constraint is invisible in development where both sides always run the
same version.

CLAUDE.md gains doc-writing and voice rules to keep docs from
accumulating appended paragraphs. design.md gains an alignment section
(glyph rails) and a layout-shift rule.
2026-07-30 15:52:50 +02:00
paseo-ai[bot] 4e6f759da9 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-30 11:31:41 +00:00
Mohamed Boudra 6fc491e622 chore(release): cut 0.2.5 2026-07-30 13:22:40 +02:00
Mohamed Boudra 95257a6353 docs(changelog): simplify 0.2.5 entry 2026-07-30 13:17:51 +02:00
Mohamed Boudra 4d7d6a7d1b docs(changelog): 0.2.5 2026-07-30 12:33:47 +02:00
Cafe Babeandtang b345e53a9b Fix Linux desktop package startup (#2654)
Restore the Linux startup behavior that shipped in 0.2.3 by reverting the generalized sandbox detection from #2439.

Co-authored-by: tang <tang@tang-zai>
2026-07-30 12:32:44 +02:00
paseo-ai[bot] d1ce2b77f7 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-29 23:58:09 +00:00
1658 changed files with 148009 additions and 32138 deletions
+2 -6
View File
@@ -1,13 +1,9 @@
---
name: release-beta
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are release candidates on the beta channel — they carry an in-place changelog entry, don't move the website download target, and publish npm only on the beta dist-tag.
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta".
user-invocable: true
---
# Release beta
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag.
Follow the **Beta flow** and **Beta release** completion checklist in `docs/release.md` end-to-end.
+1 -5
View File
@@ -6,8 +6,4 @@ user-invocable: true
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps.
Follow the applicable flow and the **Stable release (or promotion)** completion checklist in `docs/release.md` end-to-end.
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
Issues are only for bugs. Product discussions go in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH) — feature requests opened as issues get closed automatically.
Before opening, please:
+5 -2
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discord
- name: Product discussion on Discord
url: https://discord.gg/jz8T2uahpH
about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat.
about: Product discussion, including feature requests
- name: Product discussion on GitHub
url: https://github.com/getpaseo/paseo/discussions
about: Product discussion, including feature requests
@@ -1,43 +0,0 @@
name: Feature request
description: Propose a new feature or a change to existing behavior.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
- type: checkboxes
id: prior-search
attributes:
label: Prior search
options:
- label: I searched existing issues and discussions, and this isn't already proposed.
required: true
- type: textarea
id: problem
attributes:
label: What's the problem
description: What are you actually trying to do, and why is the current behavior in the way?
placeholder: |
When I'm doing X, I want to Y, but Paseo currently Z.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What would solve it
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
description: Optional. What else did you try, and why doesn't it work?
+18 -13
View File
@@ -1,27 +1,34 @@
<!--
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
You MUST read CONTRIBUTING.md before sending a PR.
-->
### Linked issue
Closes #
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
### Type of change
- [ ] Bug fix
- [ ] New feature (with prior issue + design alignment)
- [ ] Refactor / code improvement
- [ ] New feature
- [ ] Enhancement
- [ ] Refactor
- [ ] Docs
### What does this PR do
### Reasoning
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provided to Paseo users. -->
### How did you verify it
### Goals
<!-- A bullet list of what this PR wants to accomplish, include requirements and acceptance criteria. This helps lock down the scope of the PR and prevent expanding scope over the intended goal. -->
### Non-goals
<!-- A bullet list of what this PR does not want to accomplish. Add any intentional trade offs taken. This helps lock down the scope of the PR and prevent expanding scope over the intended goal. -->
### QA
<!--
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
@@ -29,15 +36,13 @@ This is the section I read most carefully. I need to see that *you* tested this,
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
- For behavior changes: the actual steps you ran, and what you observed.
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
-->
### Checklist
- [ ] One focused change. Unrelated cleanups split out.
- [ ] One focused change
- [ ] `npm run typecheck` passes
- [ ] `npm run lint` passes
- [ ] `npm run format` ran (Biome)
- [ ] UI changes include screenshots or video for every affected platform
- [ ] `npm run format` passes
- [ ] QA evidence
- [ ] Tests added or updated where it made sense
+71
View File
@@ -0,0 +1,71 @@
# Pull-request checks are routed by the behavior they prove. Package consumers
# do not inherit their dependencies' test suites; main and manual runs execute
# every contract regardless of these paths.
routing:
- ".github/ci-paths.yml"
workspace:
- ".mise.toml"
- ".tool-versions"
- "package.json"
- "package-lock.json"
- "patches/**"
- "scripts/**"
- "tsconfig.json"
- "tsconfig.base.json"
- "vitest.config.ts"
ci:
- ".github/actions/**"
- ".github/workflows/ci.yml"
format:
- ".agents/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}"
- ".github/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}"
- "**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}"
- "packages/expo-two-way-audio/**"
quality:
- "**/*.{cjs,js,json,jsx,mjs,ts,tsx}"
- "packages/expo-two-way-audio/**"
hub:
- "packages/cli/src/commands/hub/**"
- "packages/server/src/server/hub/**"
server:
- "packages/server/**"
- "packages/app/e2e/support/fixtures/recording.*"
desktop:
- "packages/desktop/**"
- "packages/app/src/desktop/**"
- "packages/server/src/server/browser-tools/**"
- "packages/app/e2e/support/**"
- "packages/app/*config.{cjs,js,ts}"
- "packages/app/package.json"
app:
- "packages/app/**"
- "packages/expo-two-way-audio/**"
sdk:
- "packages/client/**"
- "packages/highlight/**"
- "packages/protocol/**"
browser:
- "packages/app/src/!(desktop)/**"
- "packages/app/e2e/browser/**"
- "packages/app/e2e/support/**"
- "packages/app/assets/**"
- "packages/app/public/**"
- "packages/app/index.ts"
- "packages/app/*config.{cjs,js,ts}"
- "packages/app/package.json"
relay:
- "packages/relay/**"
cli:
- "packages/cli/**"
+180 -248
View File
@@ -19,110 +19,47 @@ env:
jobs:
changes:
name: changes
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }}
server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }}
desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }}
desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }}
app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }}
sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }}
playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }}
relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }}
cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }}
full: ${{ github.event_name != 'pull_request' || steps.filter.outputs.routing != 'false' || steps.filter.outputs.workspace != 'false' || steps.filter.outputs.ci != 'false' }}
format: ${{ steps.filter.outputs.format }}
quality: ${{ steps.filter.outputs.quality }}
hub: ${{ steps.filter.outputs.hub }}
server: ${{ steps.filter.outputs.server }}
desktop: ${{ steps.filter.outputs.desktop }}
app: ${{ steps.filter.outputs.app }}
sdk: ${{ steps.filter.outputs.sdk }}
browser: ${{ steps.filter.outputs.browser }}
relay: ${{ steps.filter.outputs.relay }}
cli: ${{ steps.filter.outputs.cli }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Detect affected CI jobs
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Detect affected CI contracts
id: filter
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
with:
filters: |
shared:
- '.github/workflows/ci.yml'
- '.github/actions/**'
- '.mise.toml'
- '.tool-versions'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- 'scripts/**'
- 'tsconfig.json'
- 'tsconfig.base.json'
- 'vitest.config.ts'
quality:
- 'packages/**'
- '*.cjs'
- '*.js'
- '*.json'
- '*.mjs'
- '*.ts'
server:
- 'packages/app/e2e/fixtures/recording.*'
- 'packages/client/**'
- 'packages/cli/src/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
desktop:
- 'packages/app/**'
- 'packages/cli/**'
- 'packages/client/**'
- 'packages/desktop/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
desktop_package:
- '.github/workflows/ci.yml'
- 'packages/desktop/**'
app:
- 'packages/app/**'
- 'packages/client/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
sdk:
- 'packages/client/**'
- 'packages/protocol/**'
- 'packages/relay/**'
playwright:
- 'packages/app/**'
- 'packages/client/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
relay:
- 'packages/relay/**'
cli:
- 'nix/**'
- 'packages/app/e2e/global-setup.ts'
- 'packages/cli/**'
- 'packages/client/**'
- 'packages/desktop/src/daemon/runtime-paths.ts'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
filters: .github/ci-paths.yml
- name: Validate CI workflow
run: node --test scripts/ci-workflow.test.mjs
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs
format:
name: format
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
runs-on: ubuntu-latest
env:
# This job never executes Electron. Skipping the hosted binary avoids
# unrelated npm ci failures when Electron's CDN returns 504.
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -136,15 +73,12 @@ jobs:
run: node scripts/npm-retry.mjs ci
- name: Check formatting
run: npx oxfmt --check .
run: npm run format:check
lint:
name: lint
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -169,12 +103,9 @@ jobs:
run: npm run lint
typecheck:
name: typecheck
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -188,6 +119,7 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
@@ -200,130 +132,112 @@ jobs:
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
server-tests-ubuntu:
name: server-tests (ubuntu-latest)
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: server-tests (${{ matrix.os }})
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false' }}
steps:
- name: Skip unaffected server tests
if: env.RUN_TESTS != 'true'
run: echo "No server changes detected."
steps: &server_test_steps
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
with:
fetch-depth: 0
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
if: env.RUN_TESTS == 'true'
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:server-deps
- name: Run server tests
if: env.RUN_TESTS == 'true'
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
- name: Run Hub CLI/daemon contract
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
server-tests-windows:
name: server-tests (windows-latest)
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: windows-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps: *server_test_steps
desktop-tests-ubuntu:
name: desktop-tests (ubuntu-latest)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
env:
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false' }}
steps:
- name: Skip unaffected desktop tests
if: env.RUN_TESTS != 'true'
run: echo "No desktop changes detected."
steps: &desktop_test_steps
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Run desktop tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/desktop
- name: Build app dependencies for desktop E2E
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run build:app-deps
- name: Install virtual display
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
if: ${{ runner.os == 'Linux' }}
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Install Playwright browser
if: ${{ runner.os == 'Linux' }}
run: npx playwright install chromium
- name: Upload browser tab bridge diagnostics
- name: Run desktop renderer E2E
if: ${{ runner.os == 'Linux' }}
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop
- name: Run desktop browser E2E
if: ${{ runner.os == 'Linux' }}
run: npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
env:
PASEO_DESKTOP_BROWSER_E2E_ARTIFACT_DIR: ${{ runner.temp }}/desktop-browser-e2e
- name: Upload desktop browser diagnostics
uses: actions/upload-artifact@v4
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
if: ${{ failure() && runner.os == 'Linux' }}
with:
name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e
name: desktop-browser-e2e
path: ${{ runner.temp }}/desktop-browser-e2e
if-no-files-found: ignore
retention-days: 7
- name: Build and smoke unpacked desktop app
if: >-
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
if: ${{ runner.os == 'Linux' }}
run: npm run build:desktop -- --publish never --linux --x64 --dir
env:
EP_GH_IGNORE_TIME: true
@@ -331,25 +245,28 @@ jobs:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Upload packaged smoke diagnostics
if: >-
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
uses: actions/upload-artifact@v4
if: ${{ failure() && runner.os == 'Linux' }}
with:
name: desktop-packaged-smoke-linux-x64
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
app-tests:
desktop-tests-windows:
name: desktop-tests (windows-latest)
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.app != 'false') }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
runs-on: windows-latest
timeout-minutes: 30
permissions:
contents: read
steps: *desktop_test_steps
app-tests:
name: app-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -363,6 +280,7 @@ jobs:
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
@@ -374,12 +292,9 @@ jobs:
run: npm run test --workspace=@getpaseo/app
sdk-tests:
name: sdk-tests
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.sdk != 'false') }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -393,6 +308,7 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build client dependencies
run: npm run build:client
@@ -402,90 +318,97 @@ jobs:
- name: Run client tests
run: npm run test --workspace=@getpaseo/client
- name: Run highlight tests
run: npm run test --workspace=@getpaseo/highlight
- name: Typecheck client examples
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
playwright-1:
name: playwright (shard 1/4)
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
include:
- { label: "shard 1/4", shard: 1, desktop: false }
- { label: "shard 2/4", shard: 2, desktop: false }
- { label: "shard 3/4", shard: 3, desktop: false }
- { label: "shard 4/4", shard: 4, desktop: false }
- { label: "desktop overlay", shard: "desktop", desktop: true }
name: playwright (${{ matrix.label }})
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false' }}
steps:
- name: Skip unaffected Playwright tests
if: env.RUN_TESTS != 'true'
run: echo "No Playwright changes detected."
PLAYWRIGHT_SHARD: "1/4"
PLAYWRIGHT_ARTIFACT: "1"
steps: &playwright_test_steps
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
if: env.RUN_TESTS == 'true'
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:app-deps
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true' && !matrix.desktop
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: env.RUN_TESTS == 'true' && !matrix.desktop
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: env.RUN_TESTS == 'true' && matrix.desktop
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: env.RUN_TESTS == 'true' && failure()
if: failure()
with:
name: playwright-results-${{ matrix.shard }}
name: playwright-results-${{ env.PLAYWRIGHT_ARTIFACT }}
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
relay-tests:
playwright-2:
name: playwright (shard 2/4)
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.relay != 'false') }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "2/4"
PLAYWRIGHT_ARTIFACT: "2"
steps: *playwright_test_steps
playwright-3:
name: playwright (shard 3/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "3/4"
PLAYWRIGHT_ARTIFACT: "3"
steps: *playwright_test_steps
playwright-4:
name: playwright (shard 4/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "4/4"
PLAYWRIGHT_ARTIFACT: "4"
steps: *playwright_test_steps
relay-tests:
name: relay-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -506,53 +429,62 @@ jobs:
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
cli-tests:
cli-tests-1:
name: cli-tests (shard 1/3)
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
runs-on: ubuntu-latest
name: cli-tests (shard ${{ matrix.shard }}/3)
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false' }}
steps:
- name: Skip unaffected CLI tests
if: env.RUN_TESTS != 'true'
run: echo "No CLI changes detected."
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: "1"
PASEO_CLI_TEST_SHARD_TOTAL: "3"
steps: &cli_test_steps
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
PASEO_CLI_TEST_SHARD_TOTAL: "3"
cli-tests-2:
name: cli-tests (shard 2/3)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: "2"
PASEO_CLI_TEST_SHARD_TOTAL: "3"
steps: *cli_test_steps
cli-tests-3:
name: cli-tests (shard 3/3)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: "3"
PASEO_CLI_TEST_SHARD_TOTAL: "3"
steps: *cli_test_steps
+24
View File
@@ -104,3 +104,27 @@ jobs:
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop
build-desktop-darwin:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop
- name: Verify macOS application bundle
run: |
set -euo pipefail
app="result-desktop/Applications/Paseo.app"
test -x "$app/Contents/MacOS/Paseo"
test -L result-desktop/bin/paseo-desktop
test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" = "sh.paseo.desktop"
find "$app" -name Info.plist -print0 | xargs -0 -n 1 plutil -lint
+6 -7
View File
@@ -130,7 +130,7 @@
]
}
},
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
// Raw query burn-down: 34 files total; the 28 files below still enforce the useUnistyles ban.
{
"files": [
"packages/app/src/assistant-file-links/use-file-link.ts",
@@ -154,10 +154,9 @@
"packages/app/src/hooks/use-is-local-daemon.ts",
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
"packages/app/src/hooks/use-preferred-editor.ts",
"packages/app/src/hooks/use-project-icon-query.ts",
"packages/app/src/hooks/use-settings/index.ts",
"packages/app/src/panels/terminal-panel.tsx",
"packages/app/src/projects/project-icons.ts",
"packages/app/src/projects/icons.ts",
"packages/app/src/provider-usage/use-provider-usage.ts",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
@@ -189,9 +188,9 @@
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/browser-pane.electron.tsx",
"packages/app/src/components/browser-pane.tsx",
"packages/app/src/components/browser-pane.web.tsx",
"packages/app/src/desktop/browser/pane/index.electron.tsx",
"packages/app/src/desktop/browser/pane/index.tsx",
"packages/app/src/desktop/browser/pane/index.web.tsx",
"packages/app/src/components/command-center.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/dictation-controls.tsx",
@@ -280,7 +279,7 @@
}
},
{
"files": ["**/e2e/fixtures.ts"],
"files": ["**/e2e/**/fixtures.ts"],
"rules": {
"no-empty-pattern": "off"
}
+114
View File
@@ -1,5 +1,119 @@
# Changelog
## 0.3.1 - 2026-08-09
### Added
- Unassign keyboard shortcuts and see the effective bindings in shortcut help ([#2510](https://github.com/getpaseo/paseo/pull/2510) by [@cleiter](https://github.com/cleiter), [#2985](https://github.com/getpaseo/paseo/pull/2985) by [@cleiter](https://github.com/cleiter))
- Choose a Pure black theme ([#3012](https://github.com/getpaseo/paseo/pull/3012) by [@shrimpwtf](https://github.com/shrimpwtf))
### Improved
- Running agents use a clearer animated status ring ([c0daf8e](https://github.com/getpaseo/paseo/commit/c0daf8e066e0d7379723a2a6ddea20afc71ead20) by [@cleiter](https://github.com/cleiter))
- Workspace Git updates stay responsive under file-watcher failures, heavy Git activity, and large shared-worktree repositories ([#3056](https://github.com/getpaseo/paseo/pull/3056), [#3033](https://github.com/getpaseo/paseo/pull/3033) by [@dwyanewang](https://github.com/dwyanewang), [#3078](https://github.com/getpaseo/paseo/pull/3078))
- Kimi usage refreshes correctly and shows five-hour limits and reasoning levels ([#2743](https://github.com/getpaseo/paseo/pull/2743) by [@UnbrokenHunter](https://github.com/UnbrokenHunter))
- Resize the desktop sidebar with touch ([#2953](https://github.com/getpaseo/paseo/pull/2953) by [@colonelpanic8](https://github.com/colonelpanic8))
### Fixed
- Direct LAN connections work again on macOS ([#3071](https://github.com/getpaseo/paseo/pull/3071))
- Desktop update checks no longer report an error while platform downloads are still publishing ([#3076](https://github.com/getpaseo/paseo/pull/3076))
- Copied lists keep their markers across selection boundaries ([#3055](https://github.com/getpaseo/paseo/pull/3055))
- Switching hosts keeps the active project selected ([#3051](https://github.com/getpaseo/paseo/pull/3051))
- New Workspace keeps project and isolation controls inside the composer ([#3050](https://github.com/getpaseo/paseo/pull/3050))
- Session rename is reachable from the mobile sessions menu ([#2924](https://github.com/getpaseo/paseo/pull/2924) by [@kaspesi](https://github.com/kaspesi))
- Desktop notification sounds play again ([#2582](https://github.com/getpaseo/paseo/pull/2582) by [@gstamp](https://github.com/gstamp))
- Codex slash commands omit disabled skills ([#2759](https://github.com/getpaseo/paseo/pull/2759) by [@jasonhnd](https://github.com/jasonhnd))
- Exact ignored file paths resolve correctly ([#3042](https://github.com/getpaseo/paseo/pull/3042) by [@cleiter](https://github.com/cleiter))
- Claude question notifications summarize the requested input ([#2925](https://github.com/getpaseo/paseo/pull/2925) by [@SihyeonJeon](https://github.com/SihyeonJeon))
- The crash screen stays readable and keeps its retry action reachable ([#3043](https://github.com/getpaseo/paseo/pull/3043))
## 0.3.0 - 2026-08-08
### Added
- New mobile terminal with text selection, copy, paste, and faster rendering ([#1607](https://github.com/getpaseo/paseo/pull/1607), [#2830](https://github.com/getpaseo/paseo/pull/2830))
- Redesigned sidebar with clearer status colours and rows that are quicker to scan ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi), [#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs), [#2711](https://github.com/getpaseo/paseo/pull/2711))
- Choose what a sidebar workspace row shows: host, pull request, checks, and scripts
- Name each host and give it a colour ([#2790](https://github.com/getpaseo/paseo/pull/2790))
- Search your history by workspace, agent, and branch ([#2995](https://github.com/getpaseo/paseo/pull/2995))
- Use Paseo in Korean ([#2895](https://github.com/getpaseo/paseo/pull/2895) by [@himomohi](https://github.com/himomohi), [@Kesta-bos](https://github.com/Kesta-bos))
- Run git and workspace actions from the Command Center ([#2749](https://github.com/getpaseo/paseo/pull/2749))
- Change model, reasoning, mode, plan, and fast from the Command Center ([#2274](https://github.com/getpaseo/paseo/pull/2274) by [@kedrzu](https://github.com/kedrzu))
- Fork an agent while it's running ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi))
- Launch a terminal directly from New Workspace ([#2941](https://github.com/getpaseo/paseo/pull/2941))
- Jump between prompts in a chat ([#2792](https://github.com/getpaseo/paseo/pull/2792))
- Preview HTML files ([#2712](https://github.com/getpaseo/paseo/pull/2712) by [@nicholas-salgueiro-britecore](https://github.com/nicholas-salgueiro-britecore), [@nickmaglowsch](https://github.com/nickmaglowsch))
- See Claude workflows in the subagent track ([#2933](https://github.com/getpaseo/paseo/pull/2933))
- Choose which orchestration skills Paseo installs ([#2680](https://github.com/getpaseo/paseo/pull/2680))
- Approve all ACP tool calls with one setting ([#2752](https://github.com/getpaseo/paseo/pull/2752))
- Add custom HTTP headers to direct host connections ([#2922](https://github.com/getpaseo/paseo/pull/2922))
- Pick a local branch or its origin counterpart as the base for a new worktree ([#2328](https://github.com/getpaseo/paseo/pull/2328) by [@mcowger](https://github.com/mcowger))
- Paste images from the clipboard on mobile ([#2793](https://github.com/getpaseo/paseo/pull/2793))
### Improved
- Provider data is cached in the client for a faster startup
- Recent chats stay live in the background, so switching back shows current messages right away ([#2842](https://github.com/getpaseo/paseo/pull/2842))
- Coming back to a workspace from Settings is faster ([#2791](https://github.com/getpaseo/paseo/pull/2791))
- Workspace Git status stays responsive in large repositories ([#2979](https://github.com/getpaseo/paseo/pull/2979))
- Paseo runs fewer git processes on busy machines ([#2797](https://github.com/getpaseo/paseo/pull/2797))
- Copying part of an assistant response keeps its lists, links, and formatting ([#2808](https://github.com/getpaseo/paseo/pull/2808), [#2930](https://github.com/getpaseo/paseo/pull/2930), [#2935](https://github.com/getpaseo/paseo/pull/2935) by [@cleiter](https://github.com/cleiter))
- See the full conversation from a native Claude subagent ([#2498](https://github.com/getpaseo/paseo/pull/2498) by [@ebg1223](https://github.com/ebg1223), [#2760](https://github.com/getpaseo/paseo/pull/2760))
- OpenCode subagents show their task, type, model, and token usage ([#2909](https://github.com/getpaseo/paseo/pull/2909) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
- Pi delegated tasks show their lifecycle status ([#2891](https://github.com/getpaseo/paseo/pull/2891))
- Claude remembers model and thinking choices for new workspaces ([#2912](https://github.com/getpaseo/paseo/pull/2912))
- OMP context usage updates while a turn is running ([#2503](https://github.com/getpaseo/paseo/pull/2503) by [@theslava](https://github.com/theslava))
- OpenCode reports background activity from its own busy status ([#2696](https://github.com/getpaseo/paseo/pull/2696) by [@desflynn](https://github.com/desflynn))
- Desktop browser tabs keep their state through focus changes and automation ([#2907](https://github.com/getpaseo/paseo/pull/2907))
- New worktrees start from the tracked upstream branch ([#2848](https://github.com/getpaseo/paseo/pull/2848))
- Generated workspace titles describe the task ([#2755](https://github.com/getpaseo/paseo/pull/2755))
- Relay access is opt-in when you pair a device ([#2706](https://github.com/getpaseo/paseo/pull/2706))
- Voice mode releases the audio session when recording and playback stop ([#2866](https://github.com/getpaseo/paseo/pull/2866) by [@kaspesi](https://github.com/kaspesi))
- Workspace suggestions skip Git-ignored data ([#2902](https://github.com/getpaseo/paseo/pull/2902))
- Reconnect messages distinguish daemon restarts from network interruptions ([#2931](https://github.com/getpaseo/paseo/pull/2931))
- The file viewer shows one accurate status when a file changes or is deleted ([#2670](https://github.com/getpaseo/paseo/pull/2670), [#2694](https://github.com/getpaseo/paseo/pull/2694))
- The Nix desktop package is smaller ([#2550](https://github.com/getpaseo/paseo/pull/2550) by [@colonelpanic8](https://github.com/colonelpanic8))
- The Nix desktop app uses the right icon on Linux and macOS ([#2506](https://github.com/getpaseo/paseo/pull/2506), [#2783](https://github.com/getpaseo/paseo/pull/2783) by [@colonelpanic8](https://github.com/colonelpanic8))
- Paseo Desktop builds from the Nix flake on macOS ([#2556](https://github.com/getpaseo/paseo/pull/2556) by [@colonelpanic8](https://github.com/colonelpanic8))
### Fixed
- Pi 0.84 agents no longer crash-loop on every prompt ([#2978](https://github.com/getpaseo/paseo/pull/2978) by [@dundunHa](https://github.com/dundunHa))
- Workspace file watching no longer stalls the daemon ([#2858](https://github.com/getpaseo/paseo/pull/2858))
- Messages no longer duplicate or arrive out of order after a reconnect or resume ([#2789](https://github.com/getpaseo/paseo/pull/2789), [#2718](https://github.com/getpaseo/paseo/pull/2718))
- Git status and diffs pick up changes in nested folders on every desktop OS ([#2775](https://github.com/getpaseo/paseo/pull/2775))
- Claude runtime failures report an error instead of leaving the workspace idle ([#2910](https://github.com/getpaseo/paseo/pull/2910) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- Claude replay no longer leaves stale running subagents ([#2876](https://github.com/getpaseo/paseo/pull/2876) by [@cleiter](https://github.com/cleiter))
- The Fast toggle appears for Claude Opus 5 ([#2939](https://github.com/getpaseo/paseo/pull/2939) by [@cleiter](https://github.com/cleiter))
- Agents stop with an error when their provider process exits ([#2757](https://github.com/getpaseo/paseo/pull/2757))
- Cancelling an OpenCode turn no longer breaks the next one ([#2662](https://github.com/getpaseo/paseo/pull/2662))
- ACP permission prompts no longer disappear mid-turn ([#2762](https://github.com/getpaseo/paseo/pull/2762))
- A workspace no longer looks idle while its native subagents run ([#2777](https://github.com/getpaseo/paseo/pull/2777))
- Terminal activity stops after an interrupted turn ([#2942](https://github.com/getpaseo/paseo/pull/2942))
- Windows terminals handle deferred startup failures without crashing
- Recreated workspace folders no longer remain incorrectly archived ([#2987](https://github.com/getpaseo/paseo/pull/2987))
- Restoring a merged workspace no longer leaves it inactive ([#2714](https://github.com/getpaseo/paseo/pull/2714))
- Switching workspaces no longer scrolls a chat away from where you were reading ([#2838](https://github.com/getpaseo/paseo/pull/2838))
- Images stay anchored while a conversation reloads
- Swipe and drag gestures work in the mobile sidebar ([#2709](https://github.com/getpaseo/paseo/pull/2709))
- Dictated prompts no longer disappear when you submit them ([#2745](https://github.com/getpaseo/paseo/pull/2745))
- The composer stays above the keyboard on Android
- Typing with an IME on mobile no longer loses composed text
- The composer toolbar no longer flickers when switching tabs ([#2937](https://github.com/getpaseo/paseo/pull/2937))
- Your selected project stays selected when you switch hosts ([#2700](https://github.com/getpaseo/paseo/pull/2700))
- Host connection choices stay selected through Settings and desktop refreshes ([#2905](https://github.com/getpaseo/paseo/pull/2905))
- The pull request panel updates after you switch branches ([#2699](https://github.com/getpaseo/paseo/pull/2699))
- New Workspace shows its isolation controls right away ([#2702](https://github.com/getpaseo/paseo/pull/2702))
- Sidebar shortcuts leave focus mode ([#2717](https://github.com/getpaseo/paseo/pull/2717))
- Agents started by the NixOS service no longer run in production mode ([#2697](https://github.com/getpaseo/paseo/pull/2697) by [@shin-sakata](https://github.com/shin-sakata))
## 0.2.5 - 2026-07-30
### Fixed
- Fixed the Linux Debian package ([#2654](https://github.com/getpaseo/paseo/pull/2654) by [@Neumannzc](https://github.com/Neumannzc))
## 0.2.4 - 2026-07-30
### Added
+41 -24
View File
@@ -19,7 +19,7 @@ This is an npm workspace monorepo:
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
| Doc | What's in it |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
@@ -29,11 +29,12 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
@@ -42,9 +43,12 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
@@ -54,6 +58,31 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
### Writing docs
- **Integrate, don't append.** Find the doc that owns the subject and rewrite the part that is now wrong. The standard failure is finishing a task and adding a paragraph to the bottom of the closest-looking doc; ten tasks later the doc is a pile of paragraphs in discovery order. `docs/custom-providers.md` is what that looks like.
- **Don't document logic.** Prose that restates code drifts from the code and loses. Write down what the code can't tell you: why something is shaped the way it is, the gotcha that cost an afternoon, conventions nothing enforces, constraints that span packages or versions. If a reader could get it in two minutes by opening the file, cut it.
- **One fact, one doc.** Every other mention is a link. If you are about to write the same paragraph in two docs, one of them is a link.
- **Respect the layers.** `CONTRIBUTING.md` and this file name things and link out. Activity docs like `docs/qa.md` and `docs/testing.md` set the bar for a kind of work. Subject docs like `docs/unistyles.md` own one thing completely. A layer never re-explains the one below it.
- **One subject per doc.** If the subject doesn't fit in a sentence, split the doc. A section per provider, vendor, or platform is a table plus one worked example.
- **Delete.** Obsolete sections go. Prefer a `packages/app/src/thing.ts:120` reference over a pasted block.
- **New doc?** Add a row to the table above and link it from the docs that should send readers there.
- Code-level facts belong in comments next to the code, not here.
### Doc voice
Plain and short. Second person. State the rule, then the reason when the reason isn't obvious. Match the doc you're editing.
Do not:
- Write a sentence to land a point. "It's not X, it's Y", "That's not a Z, that's a W", and every other setup-and-punchline shape.
- Add a clause that only asserts importance: "and that matters", "which is what keeps it working", "this is critical".
- Use "honest", "robust", "seamless", "powerful", "simply", "just", "delightful".
- Restate something you already said, in different words, for emphasis.
- Hedge with "generally", "typically", or "you may want to" when the answer is "do this".
- Clear your throat: "It's worth noting that", "In order to", "This section covers".
## Quick start
@@ -94,23 +123,11 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **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`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default.
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
- Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
- **The protocol stays backward-compatible. Features don't have to.** Read [docs/protocol-compatibility.md](docs/protocol-compatibility.md) before touching `packages/protocol`. The short version:
- **Protocol contract (always):** an old client parses messages from a new daemon, and a new daemon parses messages from an old client. New fields are optional; never narrow, never remove, never require. Wire schemas stay pure — no `.transform()`, `.catch()`, or `.preprocess()`.
- **Feature contract (per-feature):** gate the capability once on `server_info.features.*`, then run the feature or tell the user to update the host. No fallback paths, no defensive branches.
- **Every shim is tagged.** `// COMPAT(name): added in vX, remove after <date>` at the site that has to be deleted. `rg "COMPAT\("` is the cleanup backlog; untagged back-compat is permanent by accident.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
## Platform gating
@@ -148,12 +165,12 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
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
desktop/browser/pane/
index.electron.tsx ← Electron <webview> implementation
index.web.tsx ← plain web fallback
index.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.
Import as `@/desktop/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.
+116 -32
View File
@@ -1,56 +1,140 @@
# Contributing to Paseo
Paseo is an opinionated product maintained by one person right now.
Thank you for taking the time to contribute to Paseo.
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
## Philosophy
Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
## Becoming a maintainer
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
Here's the progression:
## Report bugs in GitHub issues or Discord
1. Get involved in the community: answer questions in Discord and on GitHub
2. Triage bugs: replicate and help fix them
3. Work on maintainer-approved features
Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH).
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
> [!IMPORTANT]
> Feature requests opened as issues will get closed automatically
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis.
You may submit a PR to fix a bug, please read the PR guidelines below.
## Product discussions go in GitHub Discussions or Discord
There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow:
- What are you trying to do?
- How do you do it today?
- Where does Paseo get in the way?
- What would the flow look like if it worked for you?
"Can you build X" is not useful because it doesn't tell me about your problem.
A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build.
Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me.
## Pull requests
✅ Will be accepted
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
- Keep it to one focused change
- Link to an issue
- Explain the problem you're solving
- Include repro steps if it's a bug
- Include QA/testing evidence
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
Here is the criteria I use to decide:
✅ Likely to be accepted
- Fits the product vision
- One focused change
- Links the bug it fixes, or the discussion behind it if there is one
- Addresses bot reviews
- Explains the problem you're solving
- QA evidence
- Automated tests
- Screenshots or video for UI changes, on every affected platform
- Says which platforms you tested and which you didn't
- Maintainer edits enabled
⛔️ Will be rejected
- Bundle unrelated changes
- Fail basic checks like typecheck, formatting or linting
- Add a feature or design change that wasn't discussed first
- Submit no evidence of testing
- Skip the linked issue
- Bundles unrelated changes
- Fails checks
- Ignores bot reviews
- Takes a feature or the design in a direction I don't want
- No QA evidence
- No tests
- Clearly fully AI-generated PR
## Requesting features
### What to expect
If you need a feature implemented, create a Github issue or a thread in Discord.
- PRs that were explicitly approved in a discussion are preferred.
- Unsolicited PR can be closed without a detailed review.
- Your PR can be narrowed, refactored, or redesigned.
- Your PR might be accepted but not merged immediately.
- You will be attributed for your work in the changelog, even if I redesign your PR
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
## QA evidence
## AI assistance
QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar.
Using AI to help write code is fine, but you must:
Pull requests without evidence will be closed.
- Ensure your agents read the docs
- Understand the code you submit
- Review and test the code yourself
What's accepted:
- The shell commands you ran, pasted with their output
- The tests you added and their results
- Before and after screenshots
- A video of the whole interaction
- Logs, requests, responses
Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static.
The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it.
## I want to become a maintainer
There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement.
## FAQ
### I'm blocked, where do I go?
Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there.
### Do you prefer Github or Discord?
I am more active on Discord for general questions.
I prefer Github for bugs because the template helps collect information more efficiently.
### Why was my feature request closed?
Issues are only for bugs. Open it as a product discussion instead, framed as a workflow.
### Why didn't my PR get a detailed review?
Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume.
### How long until someone looks at my pull request?
It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask.
### Why do you want tests and a video for a small change?
Because it's faster and safer than me checking out and running every PR myself to test it manually.
QA evidence help me take your PR seriously for a deeper review and maintainer QA.
### I only have one platform, can I still contribute?
Yes. Test what you have, and say what you didn't test.
### Can I use an agent to write my contribution?
Yes, as long as you understand what you're submitting and you tested it yourself.
### Is there a roadmap I can read?
[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next.
+2 -2
View File
@@ -7,7 +7,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
<a href="README.ja.md">日本語</a> ·
<a href="README.ko.md">한국어</a>
</p>
<p align="center">
@@ -117,7 +118,6 @@ npx skills add getpaseo/paseo
どのエージェントとの会話でも使用できます。
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
+172
View File
@@ -0,0 +1,172 @@
<p align="center">
<img src="packages/website/public/logo.svg" width="64" height="64" alt="Paseo 로고">
</p>
<h1 align="center">Paseo</h1>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a> ·
<a href="README.ko.md">한국어</a>
</p>
<p align="center">
<a href="https://github.com/getpaseo/paseo/stargazers">
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
</a>
<a href="https://github.com/getpaseo/paseo/releases">
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
</a>
<a href="https://x.com/moboudra">
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
</a>
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
<a href="https://www.reddit.com/r/PaseoAI/">
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
</a>
</p>
<p align="center">Claude Code, Codex, Copilot, OpenCode, Pi 에이전트를 위한 하나의 인터페이스</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
내 컴퓨터에서 에이전트를 병렬로 실행하세요. 데스크톱이나 휴대폰에서 배포하세요.
- **셀프 호스팅:** 에이전트는 완전한 개발 환경이 갖춰진 내 컴퓨터에서 실행됩니다. 평소 쓰던 도구, 설정, 스킬을 그대로 쓸 수 있습니다.
- **여러 제공자 지원:** Claude Code, Codex, Copilot, OpenCode, Pi를 하나의 인터페이스에서 사용할 수 있습니다. 작업마다 알맞은 모델을 고를 수 있습니다.
- **음성 제어:** 음성 모드에서 작업을 말로 지시하거나 문제를 음성으로 함께 검토할 수 있습니다. 손을 쓰지 않고 작업해야 할 때 유용합니다.
- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다.
- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다.
## 시작하기
Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다.
### 준비 사항
아래 에이전트 CLI 중 하나 이상을 설치하고 인증 정보를 설정해야 합니다.
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
### 데스크톱 앱(권장)
[paseo.sh/download](https://paseo.sh/download) 또는 [GitHub 릴리스 페이지](https://github.com/getpaseo/paseo/releases)에서 다운로드하세요. 앱을 열면 데몬이 자동으로 시작됩니다. 별도로 설치할 것은 없습니다.
휴대폰에서 연결하려면 **설정 → 호스트 → 기기 페어링**을 여세요.
### CLI / 헤드리스 환경
CLI를 설치하고 Paseo를 시작하세요.
```bash
npm install -g @getpaseo/cli
paseo
```
Paseo가 로컬에서 시작된 뒤 기기 페어링을 위한 종단 간 암호화 릴레이를 켤지 묻습니다. 거절하면 TCP, Tailscale 또는 다른 VPN으로 직접 연결할 수 있습니다. 이 방식은 서버나 원격 머신에서 유용합니다.
자세한 설치와 설정은 아래 문서를 참고하세요.
- [문서](https://paseo.sh/docs)
- [연결 가이드](https://paseo.sh/docs/connectivity)
- [설정 레퍼런스](https://paseo.sh/docs/configuration)
### Docker
Docker에서 Paseo 데몬과 셀프 호스팅 웹 UI를 실행하세요:
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo" \
-v "$PWD:/workspace" \
ghcr.io/getpaseo/paseo:latest
```
컨테이너가 시작되면 `http://localhost:6767`을 여세요. 사용하는 에이전트 CLI를 기본 이미지에 추가한 뒤, 환경 변수나 영구 `/home/paseo` 볼륨으로 인증 정보를 설정하세요. 자세한 내용은 [Docker 문서](docs/docker.md)를 참고하세요.
## CLI
앱에서 할 수 있는 모든 작업은 터미널에서도 할 수 있습니다.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # 실행 중인 에이전트 목록
paseo attach abc123 # 실시간 출력 스트리밍
paseo send abc123 "also add tests" # 후속 작업 전송
# 원격 데몬에서 실행
paseo --host workstation.local:6767 run "run the full test suite"
```
자세한 내용은 [전체 CLI 레퍼런스](https://paseo.sh/docs/cli)를 참고하세요.
## 스킬
스킬은 에이전트가 Paseo를 통해 다른 에이전트를 오케스트레이션하는 방법을 알려 줍니다.
```bash
npx skills add getpaseo/paseo
```
그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다.
- `/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다.
- `/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다.
- `/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다.
## 개발
모노레포 패키지 구성은 다음과 같습니다.
- `packages/server`: Paseo 데몬(에이전트 프로세스 오케스트레이션, WebSocket API, MCP 서버 제공)
- `packages/app`: Expo 클라이언트(iOS, Android, 웹)
- `packages/cli`: `paseo` CLI(데몬과 에이전트 워크플로)
- `packages/desktop`: Electron 데스크톱 앱
- `packages/relay`: 데몬과 클라이언트가 쓰는 릴레이 전송 및 암호화 패키지
- `packages/website`: 마케팅 사이트 및 문서(`paseo.sh`)
자주 쓰는 명령:
```bash
# 모든 로컬 개발 서비스 실행
npm run dev
# 개별 환경 실행
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# 서버 스택 빌드
npm run build:server
# 레포 전체 검사 실행
npm run typecheck
```
## 관련 프로젝트
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — 커뮤니티 테마와 Agent Skill을 제공하고, 코드 수정 없이 쓸 수 있는 데스크톱 테마 로더
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 확장 프로그램
## 라이선스
AGPL-3.0
+30 -5
View File
@@ -7,7 +7,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
<a href="README.ja.md">日本語</a> ·
<a href="README.ko.md">한국어</a>
</p>
<p align="center">
@@ -64,7 +65,7 @@ You need at least one agent CLI installed and configured with your credentials:
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, open **Settings → your host → Connections → Pair a device**.
To connect from your phone, open **Settings → your host → Pair Device**.
### CLI / headless
@@ -75,11 +76,12 @@ npm install -g @getpaseo/cli
paseo
```
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Connectivity guide](https://paseo.sh/docs/connectivity)
- [Configuration reference](https://paseo.sh/docs/configuration)
### Docker
@@ -103,7 +105,7 @@ Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo run --provider codex/gpt-5.5 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
@@ -115,6 +117,30 @@ paseo --host workstation.local:6767 run "run the full test suite"
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## TypeScript SDK
Build issue integrations, dashboards, and orchestration services with `@getpaseo/client`:
```ts
import { createPaseoClient } from "@getpaseo/client";
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
await client.connect();
const agent = await client.agents.create({
config: { provider: "codex/gpt-5.5" },
cwd: "/Users/me/dev/storefront",
prompt: "Review the current diff and name the riskiest change.",
});
const result = await agent.waitForFinish();
console.log(result.lastMessage);
await client.close();
```
See the [SDK quickstart](https://paseo.sh/docs/sdk/quickstart), [recipes](https://paseo.sh/docs/sdk/recipes), and [API reference](https://paseo.sh/docs/sdk/reference).
## Skills
Skills teach your agent to use Paseo to orchestrate other agents.
@@ -126,7 +152,6 @@ npx skills add getpaseo/paseo
Then use them in any agent conversation:
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
+2 -2
View File
@@ -7,7 +7,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
<a href="README.ja.md">日本語</a> ·
<a href="README.ko.md">한국어</a>
</p>
<p align="center">
@@ -117,7 +118,6 @@ npx skills add getpaseo/paseo
然后在任意 agent 对话中使用:
- `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。
- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops),也可以加 verifier。
- `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。
- `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。
+12
View File
@@ -66,6 +66,18 @@ CORS is not a complete security boundary. It controls which browser origins can
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
## HTML file preview
Previewing an `.html` file in the file pane renders it as a page, so markup an agent wrote — or markup that arrived with a repo you cloned — executes when you open it. The preview is built to contain that, not to trust it.
The document loads with an opaque origin and a policy that permits inline script and style and refuses everything else: no remote script, font, image, or media; no `fetch`, XHR, WebSocket, or beacon; no form posts; no plugins; no nested frames. It has no access to Paseo's DOM, and storage and cookie APIs throw inside it rather than returning anything. It cannot navigate the top window, and it cannot open popups. It cannot read any file but itself.
One gap remains on web and desktop: a sandboxed document may navigate _itself_, and no CSP directive in current browsers prevents that. `navigate-to` was dropped from CSP Level 3 and is not enforced, and `<meta http-equiv="refresh">` needs no script at all. A hostile page can therefore reach a server by navigating away, carrying data available inside the preview, such as its own contents, browser and device properties, user input inside the page, and your IP address. It cannot read Paseo, another file, storage, or cookies.
Native builds narrow this gap rather than closing it outright. The WebView refuses every navigation after the initial document, but that decision is made in the app's JavaScript, and on Android the WebView falls back to allowing a navigation when the decision doesn't come back in time. Treat it as a strong mitigation, not a guarantee: if the JS thread is stalled at the moment a page navigates, the same leak is possible there too.
If you don't trust a page, read it in `Source`, which executes nothing. Source is available as an editable view on supported web hosts and a read-only view everywhere else.
## Agent authentication
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
+28
View File
@@ -24,6 +24,15 @@ primed.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
workflows all live in the CLI process, and the completion notification that would have woken the
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
Only the Claude provider does this today; the others still report a death only when a turn happens to
be in flight.
### Cancellation
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
@@ -40,6 +49,9 @@ Runtime ownership is resolved from explicit workspace ID and caller context, nev
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
## Provider-managed child agents
@@ -107,6 +119,8 @@ Agent lifecycle status stays literal: a parent agent is `idle` when its own turn
Workspace status is an aggregate activity signal computed **per `workspaceId`**. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute `running` to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship.
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
## The subagents track
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
@@ -123,6 +137,20 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
Provider descriptors may include one compact subtitle. The provider owns its contents and formatting; clients display and truncate it without interpreting provider-specific model, thinking, or usage fields.
### Claude provider subagents: the task protocol
Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `task_updated` / `task_notification` / `task_progress`), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (`subagents/live-source.ts`) and the replay source (`subagents/replay-source.ts`) both translate into one observation vocabulary (`subagents/observation.ts`), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types:
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
+12 -1
View File
@@ -67,6 +67,17 @@ npm run android:production # Release build
npm run android:clear # Remove generated Android project
```
For a production-ID release APK that local Android profiling tools can attach to:
```bash
PASEO_PROFILE_BUILD=1 npm run android:production
```
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
Or from `packages/app`:
```bash
@@ -118,7 +129,7 @@ cd android
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
For a single-ABI APK, pass React Native's architecture property to Gradle:
+12 -8
View File
@@ -76,8 +76,6 @@ not retain non-Git directories.
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
### `packages/protocol` — Wire schemas and shared protocol types
@@ -119,13 +117,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
- `paseo daemon start/stop/restart/status/pair/set-password`
- `paseo chat ls/create/inspect/post/read/wait/delete`
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo script ls/start/stop`
- `paseo loop run/ls/inspect/logs/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
- `paseo heartbeat create/update/delete`
- `paseo workspace create/ls/archive`
- `paseo workspace create/ls/rename/archive`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
@@ -141,6 +137,7 @@ Enables remote access when the daemon is behind a firewall.
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
@@ -228,6 +225,11 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
Agent snapshots optionally carry the daemon-owned active turn identity, and turn lifecycle stream events
optionally carry the same `turnId`. New clients use these fields when present and normalize an old daemon's
status once at the directory boundary rather than maintaining a second activity model.
- Terminal subscribe/input/capture commands
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
@@ -293,7 +295,10 @@ initializing → idle ⇄ running
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline sequence allocation is append-only with epochs (each run starts a new epoch). The one
permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted
prompt; it preserves the row's sequence, content, and timestamp. Storage uses sequence numbers for
client-side dedup; the default fetch page is 200 items.
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
@@ -374,9 +379,8 @@ $PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
├── chat/ # Chat rooms
├── projects/icons/ # Custom project icon images
├── schedules/ # Scheduled-agent definitions and runs
├── loops/ # Loop runs and logs
├── config.json # Daemon config (mutable)
├── daemon-keypair.json # Daemon identity for relay/E2EE
├── push-tokens.json # Mobile push tokens
+14 -5
View File
@@ -5,7 +5,7 @@ It validates the compositor behavior that unit tests cannot see:
- the resident automation `<webview>` starts in the production parking state;
- the parked guest remains paintable and has a copyable viewport frame;
- the resident webview guest is sized to 1280x800 logical pixels;
- a never-presented resident webview guest defaults to 1280x800 logical pixels;
- multiple resident webviews are parked as an overlapping stack without per-capture
stacking changes;
- a newly attached resident webview whose first useful frame is delayed can be captured
@@ -82,10 +82,19 @@ is usually saved as 2560x1600.
Electron captures copy from the guest web contents' compositor surface. A resident
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
overlap at `left:0`, `top:0`.
copyable surface. Each production webview keeps one permanent body-level surface. Presenting
or parking changes that surface's geometry without reparenting the webview. The parking state
uses `left:0`, `top:0`, `width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and
`pointer-events:none`. The webview stays at its resolved logical viewport, defaulting to
1280x800 before first presentation, with `display:inline-flex` at `left:0`, `top:0`.
Presentation resolves responsive guests to the pane's exact pixel dimensions after the surface
has visible bounds. Do not apply percentage guest sizing against the parked surface: Electron
exposes the 1x1 parking geometry as a real guest resize before expanding it again.
The permanent browser host and `overlay-root` are explicit sibling paint planes. The browser
plane stays below the overlay plane regardless of body insertion order; menus keep their relative
layering inside `overlay-root`. Activating a presented browser also focuses its registered guest
`WebContents` in main so macOS assigns keyboard first-responder ownership to the page.
There is no renderer prep/restore handshake. Main disables guest background throttling
once when the webview attaches, then screenshot capture uses the shared serialized queue,
+4
View File
@@ -14,6 +14,10 @@ For testing rules, see [testing.md](testing.md).
- **`interface`** over `type` when both work.
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
## Shell scripts
- Bash scripts always use `#!/usr/bin/env bash`. Never hard-code `/bin/bash` or `/usr/bin/bash`; those paths are not portable to environments such as NixOS.
## Comments and noise
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
+8 -1
View File
@@ -245,6 +245,8 @@ requires_openai_auth = false
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word.
Example: two different Anthropic accounts as separate profiles:
```json
@@ -584,6 +586,11 @@ When you launch an agent with an ACP provider:
4. Paseo creates a session and sends prompts through the ACP protocol
5. The agent streams responses, tool calls, and permission requests back over stdout
Every ACP provider exposes an **Auto Accept** toggle. Enable it per session to let Paseo approve
ACP permission requests without surfacing each prompt. If the provider sends no allow option,
Paseo leaves the request for you to answer. Unattended agents enable Auto Accept unless you
explicitly disable it.
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
```json
@@ -691,7 +698,7 @@ Each entry in the `models` array:
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
### Gotcha: `extends: "claude"` with third-party endpoints
+97 -166
View File
@@ -54,13 +54,10 @@ $PASEO_HOME/
│ └── {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
│ ├── workspaces.json # Workspace registry
│ └── icons/ # Host-local custom project icon images
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
@@ -99,7 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `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.) |
| `internal` | `boolean?` | Whether this is a system-internal agent |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
@@ -186,9 +183,12 @@ Single file, validated with `PersistedConfigSchema`.
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
mcp: { enabled: boolean, injectIntoAgents: boolean },
git: { maxProcessesPerSecond: number, maxProcessConcurrency: number },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
terminalProfiles: TerminalProfile[], // named shell commands; omitted means DEFAULT_TERMINAL_PROFILES
agentProfiles: AgentProfile[], // named agent launch bundles; omitted means none
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false
auth: { password: string } // bcrypt hash, optional
},
app: {
@@ -233,6 +233,55 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults.
### Profile lists
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
array, never merges entries, so a client sends the complete next list on every add, edit, reorder
and remove. List order is the display order.
Absent and empty mean different things for terminal profiles — omitting the key falls back to
`DEFAULT_TERMINAL_PROFILES`, while `[]` means the user removed them all. Agent profiles have no
defaults, so both mean none.
`PersistedConfigSchema` parses strictly, so a daemon that predates a field drops it on write
rather than storing something it cannot describe. That is why the client gates the agent profiles
UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an
older daemon.
### Git process limits
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
second, and the concurrency limit defaults to `8`:
```json
{
"daemon": {
"git": {
"maxProcessesPerSecond": 64,
"maxProcessConcurrency": 8
}
}
}
```
`maxProcessesPerSecond` limits Git process starts in any one-second interval. The allowance can
start as a burst; it does not wait for earlier processes to exit. `maxProcessConcurrency` limits
the number of Git processes that have started but not exited. Every Git command uses both limits,
including initial workspace reads, filesystem-triggered refreshes, background checks, and explicit
requests.
Environment variables override `config.json`:
| Environment variable | Setting |
| ------------------------------------ | ------------------------ |
| `PASEO_GIT_MAX_PROCESSES_PER_SECOND` | `maxProcessesPerSecond` |
| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` |
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the
daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon.
For a desktop-managed daemon, fully quit and reopen Paseo Desktop.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
@@ -305,7 +354,7 @@ One file per schedule. ID is 8 hex characters.
### 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
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, providerOptions?, featureValues?, systemPrompt?, mcpServers? } }` — create a new agent
### Nested: ScheduleRun
@@ -322,149 +371,30 @@ One file per schedule. ID is 8 hex characters.
---
## 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. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
| 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 |
| `modeId` | `string?` | Default mode ID |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifierModeId` | `string?` | Override mode ID 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
## 4. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
| Field | Type | Description |
| -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `customIconRevision` | `string \| null` | Identifies the host-local custom icon stored under `projects/icons/`. Null means the icon is discovered by scanning the project directory. |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
Uploading a file and pasting a website or image URL are two ways of _acquiring_ the same custom
icon. The client fetches URL imports and sends their bytes through the upload RPC. The daemon never
receives or fetches the URL; it validates the uploaded bytes, stores them, and records a new
`customIconRevision`. Going back to automatic deletes the stored image, as does removing the
project.
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
@@ -476,29 +406,30 @@ workspace together with its owning project.
---
## 7. Workspace Registry
## 5. 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` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| Field | Type | Description |
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
@@ -509,7 +440,7 @@ than treating it as valid.
---
## 8. Push Token Store
## 6. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
@@ -523,7 +454,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
---
## 9. Daemon meta files
## 7. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
+33 -11
View File
@@ -98,7 +98,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
@@ -122,7 +122,21 @@ The whitespace is the design.
---
## 8. Responsiveness
## 8. Alignment
Things align to their glyphs, not to their boxes. A row's leading icon, its title, and the label of the button in its trailing slot sit on the same rails — the ink lines up, not the padding, not the touch target, not the hover background.
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
One row off the rail makes the whole card look unconsidered.
---
## 9. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
@@ -143,7 +157,7 @@ A new list+detail feature copies the settings shell. A new workspace-shaped feat
---
## 9. Copy and voice
## 10. 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.
@@ -165,7 +179,7 @@ Terminology:
---
## 10. States
## 11. 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.
@@ -185,9 +199,11 @@ Partial failure (a list mostly fine but one source errored) is a bordered banner
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`.
Changing state must not move the layout. A row that grows when its badge arrives, a card that reflows when a count resolves, a list that jumps as data streams in — all wrong. Reserve the space the loaded state will need, so the skeleton, the spinner, and the content occupy the same box. A surface that shifts under the user stops feeling calm.
---
## 11. List rows
## 12. 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`).
@@ -203,24 +219,30 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
---
## 12. Status pills and badges
## 13. 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.
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
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.
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status token. Which token a given agent state maps to is owned by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`); a row, a group header, and a project icon all call it rather than restating the mapping. They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
Identity badges — the project icon, the sidebar host badge, and the PR-panel participant avatar — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Project icons and PR avatars use it as a fill with a white letter — that is `identityColor`, one theme-independent hex per identity. Host badges use it as a _foreground_ on both the glyph and the label, which is a different contrast problem that the fill table cannot solve: no single hex clears 4.5:1 against both a near-white and a dark sidebar. Foregrounds therefore come from `identityForeground(name, colorScheme)`, one set per scheme, hue unchanged. That set is generated on the **status family's** lightness and chroma fraction, because a meta row puts a host badge beside a CI check and a diff stat, and two families at different lightness make the brighter one shout. Change the status band and this one changes with it. A host with no color assigned falls back to `foregroundMuted`. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
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
## 14. 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.
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds and the identity color table are the documented exceptions (§13), 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.
@@ -233,7 +255,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
---
## 14. Canonical surfaces by pattern
## 15. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+83 -10
View File
@@ -19,8 +19,29 @@ Root checkout dev is intentionally split across terminals:
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development`,
so development-only providers such as Mock Load Test are available. Packaged
desktop launches always force the daemon to production mode.
The web and desktop dev launchers pass the current Git branch to Metro as
`EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL`. The expanded desktop sidebar shows it in
the titlebar row. Production builds leave the variable unset and show no label.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
## Nix desktop package
The flake exposes `packages.<system>.desktop` on Linux and macOS:
```bash
nix build .#desktop
```
Linux produces the `paseo-desktop` launcher and desktop entry. macOS produces
`Applications/Paseo.app` plus the `paseo-desktop` launcher. Both use the nixpkgs
Electron runtime and the checkout's built daemon, client, and renderer rather
than downloading a published desktop release.
### PASEO_HOME
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
@@ -98,10 +119,23 @@ The iOS simulator shares the Mac's loopback, so `localhost:<port>` reaches the h
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled so
renderer CPU profiles can be captured through CDP. By default it passes
`--remote-debugging-port=0`, so Chromium atomically asks the OS for an available
port and prints the selected DevTools endpoint. Set
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when a QA workflow requires a validated,
fixed port.
Desktop dev also scopes Electron `userData` to the current dev root. This prevents
desktop-only environment inherited by terminals opened inside Paseo from coupling
a new worktree instance to the parent desktop instance's profile or single-instance
lock.
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
detach Electron: either change leaves an orphan holding the worktree's single-instance
lock and broken output pipes.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with:
@@ -111,8 +145,9 @@ npm run verify:electron-cdp --workspace=@getpaseo/desktop
```
The verifier reads the same `EXPO_PORT` and
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set both when
testing an isolated instance on non-default ports.
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set an
explicit remote-debugging port for verifier runs, and set both when testing an
isolated instance on non-default ports.
When running a dedicated Electron QA instance against a non-default Expo port, set
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
@@ -215,6 +250,27 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Git process pressure
If Git refreshes consume too much CPU, disk, or antivirus capacity, especially on Windows, reduce
the daemon-global Git process limits in `$PASEO_HOME/config.json`:
```json
{
"daemon": {
"git": {
"maxProcessesPerSecond": 5,
"maxProcessConcurrency": 4
}
}
}
```
Restart the daemon with `paseo daemon restart`. If Paseo Desktop manages the daemon, fully quit and
reopen the desktop app. Lower values reduce machine pressure but make Git-backed workspace state and
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
semantics, and environment-variable overrides.
### Agent Tool Catalog Measurement
Measure the MCP `tools/list` payload that Paseo injects into agents with:
@@ -228,6 +284,23 @@ tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
for machine-readable output.
## Worktree starting refs
A new worktree starts from the current branch's upstream, or the local branch when it has no
upstream. This keeps unpushed local commits out of new workspaces by default. The picker collapses
identical refs; divergent local or non-origin refs remain explicit, qualified choices.
The daemon sends the exact upstream ref because the remote and branch names cannot be inferred.
Worktrees retain that ref for comparisons and updates from base while exposing its branch name to
the UI. Merging into base requires a mutable local target: `origin/main` maps to local `main`, while
another remote fails closed until the worktree records an explicit local target. Older daemons omit
the optional field and retain the previous local-first behavior; older worktree metadata without the
exact ref also resolves through its stored branch name.
Worktrees inherit committed Git state. Before lifecycle setup, Paseo copies the source checkout's
`paseo.json` over the worktree copy so saved Project Settings apply without a commit. Other
uncommitted source-checkout changes are not copied.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
@@ -237,9 +310,9 @@ Lifecycle commands run in the worktree through a stable script shell: `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
hook is unset. ACP single-string terminal commands use the same non-login Bash
behavior on macOS/Linux, but preserve their existing `cmd.exe /c` string semantics
on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
Because the shell differs per platform, a lifecycle command that must run
@@ -391,7 +464,7 @@ install.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
Canonical automation uses `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
```bash
npm run cli -- ls -a -g # List all agents globally
+24 -12
View File
@@ -2,20 +2,32 @@
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
- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
Two files:
- `packages/app/src/components/material-file-icons.ts` — the vendor table. `SVG_ICONS` maps icon
names to SVG strings copied verbatim from the theme, `EXTENSION_TO_ICON` maps extensions to icon
names, and `getRawFileIconSvg(fileName)` looks one up with a generic fallback. Keep it a faithful
copy so re-copying an icon stays a mechanical edit.
- `packages/app/src/components/file-icon-svg.ts` — `getFileIconSvg(fileName)`, the only thing
anyone should import. It desaturates the vendor colours and caches the result.
Render with `<MaterialFileIcon fileName size />` rather than reaching for `SvgXml` yourself.
## Why the colours are toned down
material-icon-theme picks its palette for VS Code's file tree, where the icon is the only colour on
the row. Ours share rows with status dots, diff stats, and check badges, and a column of them at
full chroma is the loudest thing in the panel.
Every hex in the SVG is pulled toward neutral at the same perceived lightness (`desaturateHexColor`
in `packages/app/src/utils/color.ts`, OKLab — scaling HSL saturation instead would darken the
yellows and barely touch the blues). Hue survives, which is the only part of an icon's colour that
carries meaning at 16pt.
`ICON_CHROMA` in `file-icon-svg.ts` is the single knob. Move it; do not add per-icon overrides, or
we are back to hand-picking 53 colours.
## Adding a new icon
+19
View File
@@ -0,0 +1,19 @@
# File observation
Use `packages/server/src/server/file-observer` for recursive filesystem observation. Create one observer service at the owning service boundary, subscribe roots through it, and close it during owner shutdown. Keep platform watchers, directory discovery, exclusion reconciliation, event batching, failure cleanup, diagnostics, and teardown inside the module. A consumer supplies a root and excluded subtrees; it must not branch on the platform or manage child watchers.
Do not replace the Linux implementation with `fs.watch({ recursive: true })`. Node 22 implements Linux recursion in JavaScript by walking the tree and watching every file and directory. Paseo's directory-only Linux watcher already exhausted resources before ignored-tree pruning and a watcher cap were added in [#794](https://github.com/getpaseo/paseo/pull/794).
Closing an observation must remain safe after its root is renamed or removed. Workspace archive removes owned worktrees while releasing observation references, so teardown cannot assume the watched path still exists.
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
+31 -1
View File
@@ -124,8 +124,13 @@ quietly relying on:
renders inside its host, not necessarily at window origin. Position anchored
content relative to the host: `anchorRect - hostRect`. This is what
`measureFloatingPanelPortalHost()` is for.
- **React context.** `@gorhom/portal` is not a React portal — a real one keeps
context, this one does not. It stores the element and the host renders it, so
context resolves at the _host's_ position. Everything provided between
`PortalProvider` in `app/_layout.tsx` and your sheet is invisible inside it.
This is why app-wide providers wrap `PortalProvider` rather than the reverse.
The fix for transforms is Gotcha 3.
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
@@ -236,6 +241,28 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
## Gotcha 7 — A sheet cannot read context from its call site
React cannot copy contexts reflectively, so the only way across the teleport in
Gotcha 2 is to render the providers a second time, with values captured on the
near side where they are still readable. `IsolatedBottomSheetModal` takes a
`contextBridge` for exactly that:
```tsx
const contextBridge = useCallback<ContextBridge>(
(content) => <ThingContext.Provider value={thing}>{content}</ThingContext.Provider>,
[thing],
);
```
The prop is **required**, and `null` is a real answer. A sheet whose content
needs nothing local should have to say so, because the failure mode is silent
until someone adds a `useContext` deep inside and it throws on device only —
never on web, where the desktop path uses a real portal. `menu-surface.tsx`
bridges the menu's two contexts; the rest pass `null`.
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
## Recipe for a new anchored panel
Before you write a new one, ask:
@@ -258,3 +285,6 @@ Before you write a new one, ask:
bounded**. Verify before you commit.
Then copy the closest canonical file and trim.
Building a **menu** rather than a bare panel? Don't. Use the menu engine — it already solves
everything above, plus submenus, sheets, and hover intent. See [menus.md](menus.md).
+9 -2
View File
@@ -10,6 +10,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Host badge** — The item on a sidebar workspace row's meta row naming which host that workspace lives on. Three things gate it, and they compose as a lattice rather than competing: the sidebar must span more than one host (automatic), **Show → Host** in the sidebar's display preferences must be on (global, all hosts at once), and then that host's own display mode decides _how_ it draws — **Name** / **Icon only** / **Hidden**. Name, color, and display mode are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. Never add a fourth way to hide it. UI: the "Appearance" group on a host's settings page, and the "Show" submenu in the sidebar's display preferences. Code: `selectHostBadges` (`packages/app/src/hosts/appearance.ts`) takes the first two as its `enabled` input; `WorkspaceMetaRow` (`packages/app/src/components/sidebar/workspace-meta-row/index.tsx`) draws it. Don't confuse with **Status pill**, which reports state rather than identity.
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
@@ -23,12 +24,16 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Workspace script** — A named command a workspace can run, declared in `paseo.json`. Two types: `service` and `script` (a command that runs and finishes). UI: "Scripts" for the panel and the menu, both of which list either type. Code: `WorkspaceScriptPayload` (`packages/protocol/src/messages.ts:3126`). Don't confuse with **Service**, which is one of the two types rather than a synonym for the pair.
- **Service** — A workspace script of type `service`: long-running, may bind a port, and carries `health` of `healthy | unhealthy | null` once the daemon has observed it. The only type a sidebar workspace row reports, because a running plain script says nothing about whether the workspace is up; the row names the service and turns danger when its health fails, and where several run the failing one takes the row's single slot. UI: "Services" in the sidebar's display preferences (**Show → Services**). Code: `selectWorkspaceServiceSummary` (`packages/app/src/components/sidebar/workspace-meta-row/service-summary.ts`), `WorkspaceScriptHealthSchema` (`packages/protocol/src/messages.ts:3124`). Forbidden: "Service" as a label for the Scripts panel, which lists both types.
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent).
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
@@ -40,7 +45,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
- **Product discussion** — Where non-bug product talk goes: [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) and `#product` in Discord. Covers workflows, features, and product direction. The container term; the ask inside it is always a **workflow** — what you're doing, how you do it today, where Paseo gets in the way. Issues are bugs only. Forbidden: "Feature request" as a Paseo surface, label, or invitation; the phrase is only used when answering someone who arrived with it. Don't invent "feature discussion", "idea", or "workflow thread" as synonyms. See [CONTRIBUTING.md](../CONTRIBUTING.md).
## Inconsistencies (documented, not papered over)
+16
View File
@@ -96,6 +96,21 @@ Any gap between A and B (margins between siblings inside the same parent) is par
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
### Failure mode 4 — A hover-revealed trigger unmounts its own open menu
A kebab that only exists while its row is hovered opens a menu, and that menu takes the pointer
off the row: a sheet slides up over it, a popover covers it. The row un-hovers, the trailing
overlay unmounts, and the trigger goes with it — taking the menu's open state, which lived
inside the trigger's subtree. The surface itself is already presented in a portal, so nothing
unmounts it and nothing is left that can close it. On a bottom sheet that means a full-screen
backdrop swallowing every click until reload.
Lift the menu's open state to whatever decides to render the trigger, and keep the trigger
rendered while the menu is up. `useOpenKebabMenuVisibility`
(`packages/app/src/components/sidebar/use-open-kebab-menu-visibility.ts`) is that shape for the
sidebar rows: it owns `open`, hands the menu its controlled props, and ORs `open` into the
row's own `showKebab`.
## Native fallback
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
@@ -134,5 +149,6 @@ Before opening a PR that touches hover:
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
+36 -9
View File
@@ -2,7 +2,9 @@
Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does
not register it with a Hub. The relationship begins only when a user runs
`paseo hub connect <url> --token <token>` from the daemon machine.
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
## Connection and authority
@@ -44,9 +46,30 @@ daemon, execution, and agent identity with that terminal state. Paseo never stor
replays the original prompt. A duplicate create returns the existing agent without starting another
turn.
Hub creates use the same agent creation path as trusted clients. They may select any existing
worktree target shape. Execution completion policy remains outside the daemon: a completed agent
turn does not imply that the Hub execution is terminal.
Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and
terminals. Local checkout and worktree targets select only the workspace backing and isolation; the
Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as
trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native
`providerOptions` for the agent session. The daemon keeps that configuration in its private agent
record so provider sessions can recover after a restart; neither ordinary client snapshots and
updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the
supported provider keys.
Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references.
Every reference must name an MCP server injected by the same create request. The daemon translates
only those identities into the selected provider's native approval configuration. The protocol
cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask
and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended
Hub creation instead of broadening access or waiting for an invisible prompt.
When a create request includes a tool policy, a successful response includes
`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution.
A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth,
not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants
and the provider's local or managed policy remain the authorization controls.
Execution completion policy remains outside the daemon: a completed agent turn does not imply that
the Hub execution is terminal.
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
@@ -56,11 +79,11 @@ If no execution exists for that authenticated daemon and execution ID, interrupt
success because the requested stopped or archived state already holds. An execution owned by another
daemon is indistinguishable from a missing execution and is never exposed or affected.
Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent.
When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the
workspace through the shared workspace archive service, so the backing directory is removed only
after its final active workspace reference disappears. Local and shared checkouts archive only the
execution-owned agent.
Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's
required workspaceId and sends it through the shared workspace archive service. The service archives
that workspace's agents and terminals, then removes Paseo-owned backing directories only after their
final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces
sharing a backing directory remain active.
## Disconnect and revocation
@@ -78,9 +101,13 @@ opening a Hub socket. This also covers an enrollment whose request may have succ
response was lost. `--force` removes local authority immediately and warns that remote revocation may
still be pending.
`paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly.
## Cross-repository compatibility
The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
Postgres. That compatibility fixture is not a package dependency or fallback implementation.
Its `hub-e2e` ACP provider accepts only exact tool names on the injected `hub` MCP server. Other
custom ACP providers remain unsupported for unattended preapproval.
+4 -2
View File
@@ -9,11 +9,12 @@ Paseo client UI translations live in `packages/app/src/i18n`.
- `es`
- `fr`
- `ja`
- `ko`
- `pt-BR`
- `ru`
- `zh-CN`
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "ko" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Korean maps from system locales `ko` and Korean regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
## Translation Scope
@@ -23,7 +24,7 @@ Do not translate agent output, daemon output, terminal contents, file paths, pro
## Adding Copy
English source strings live in `packages/app/src/i18n/resources/en.ts`. Simplified Chinese strings live in `packages/app/src/i18n/resources/zh-CN.ts`.
English source strings live in `packages/app/src/i18n/resources/en.ts`. Each supported locale has a matching resource in `packages/app/src/i18n/resources`.
For migrated screens and components, use `useTranslation()` and pass translated text into UI primitives. Low-level primitives such as `<Button>` do not import translation state unless they own the text they render.
@@ -89,3 +90,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.
- Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales.
+156
View File
@@ -0,0 +1,156 @@
# Menus
One engine, in `packages/app/src/components/ui/menu/`. `dropdown-menu.tsx` and `context-menu.tsx`
are wrappers over it and differ only in what opens them — a press, or a long press / right click.
Import the `DropdownMenu*` or `ContextMenu*` names as before; reach for `@/components/ui/menu`
directly only when you are building a third trigger shape.
Do not add a third menu implementation. The two that existed were byte-identical from
`computePosition` down, and the copies drifted: only one of them ever grew a sheet.
## Two presentations
`MenuRoot` picks one from form factor, never from platform — a tablet in a narrow split view
sheets the same way a phone does.
| Screen | Surface | Submenus |
| ------------------------------ | ------------------------------- | -------------------------------------------------- |
| Wide | Popover anchored to the trigger | Flyout overlapping the row, opened by hover intent |
| Compact, `compactMode="sheet"` | Bottom sheet | The page is replaced in place, with a back header |
| Compact, default | Popover | Same as wide |
`compactMode` defaults to `"popover"`, so adopting the sheet is per-menu. That is deliberate:
flipping every menu in the app to sheets at once is not a change anyone can review. Opt a menu in
when you have actually looked at it on a phone.
`ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press.
Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead.
## Selecting an item on iOS
An item that closes the menu runs its action after a fixed grace period on iOS, not immediately:
a native presenter launched while UIKit is still tearing down the surface can hang. Both surfaces
unmount the moment the menu closes, so the wait is a timer rather than the surface's own dismissal
callback — a callback fired from inside a subtree that is being removed is racing its own removal,
and it loses. Selections used to be dropped entirely for exactly that reason.
The consequence for callers: an `onSelect` on iOS runs a beat after the press. Don't add a second
delay on top of it, and don't read state that the same press mutated.
## Pages
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
matches:
```tsx
<MenuSurface pages={pages} sheetTitle="Display">
<MenuSubTrigger id="grouping" value="Project">
Grouping
</MenuSubTrigger>
</MenuSurface>
```
Pages are data rather than nested children because the popover renders them as _siblings_ of the
root surface — a flyout nested inside the root's box would be clipped by its `overflow: hidden`.
Declaring them separately is what lets one model drive both presentations.
`menu-navigation.ts` holds that model, and it is pure: the open flyout chain and the mobile push
stack are the same path, so the popover renders every entry in it and the sheet renders only the
last. Nothing else differs between the two.
Opening a submenu truncates the path to the depth of the trigger that opened it. Without that,
sliding the pointer across a row of triggers would stack up every flyout it passed instead of
swapping between them.
## Hover intent
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
of backdrop between the two that belongs to neither surface, and every pixel of it is a chance to
dismiss the menu you are reaching for. Overlapping deletes the strip; don't reintroduce the gap
and try to cover it with a longer timer.
On top of that, a flyout opens after the pointer rests ~90ms, closes ~260ms after it leaves, and
cancels its own pending close while the pointer is inside it. The grace still matters because the
pointer crosses sibling rows on the way down into the flyout.
Hover lives on a plain `View`, never on a `Pressable` — see [hover.md](hover.md), which owns that
rule. Hover only fires on web, which is exactly where flyouts exist; everywhere else the page
opens on press.
## Item states
`selected` and `active` are different questions and must not be merged.
| Prop | Means | Draws |
| ---------- | -------------------------- | ------------------------- |
| `selected` | This is the chosen value | A check, and nothing else |
| `active` | This row's submenu is open | The fill, and no check |
A selected row does **not** get a background. A check and a fill are two separate claims about the
same state, and showing both makes a chosen row compete with the row the pointer is actually on.
`showSelectedCheck` moves the check to a reserved leading column when a group needs to stay
aligned whether ticked or not; otherwise it sits at the trailing edge and the leading slot is free
for the option's icon.
Give options icons; leave the root rows without them. The root is labels and their current values,
and a column of icons there is decoration competing with the values you actually came to read.
## Surface details
- The hover fill is **inset from the surface's edges and rounded** — a chip inside the menu, not a
band across it. The inset is taken _out of_ the row, never added to it: padding gives up what
margin takes, so labels sit at the same 13pt they always did. Insetting by growing the row is how
the menu ends up taller than it started.
- **A row is as tall as what is driving it** — 28pt for a pointer, 40pt below `md` for a thumb. The
split is on breakpoint, not on `presentation`: the compact popover that `compactMode` defaults to
is worked with a thumb just as a sheet is, and sizing off the sheet would leave it at the desktop
height. `md` is where `useIsCompactFormFactor` divides, so row height and the popover/sheet choice
turn over together.
- The desktop height only holds because the label's `lineHeight` is pinned — 18 line + 8 padding +
2 border is exactly 28. Leave it to the platform and content outgrows `minHeight`, which then does
nothing. Compact is the other way round: `minHeight` leads and the label centres in it.
- **A row owns its fill; `MenuPage` owns the spacing between rows.** The vertical inset above the
first row and below the last, and the gap between rows, are the page's — one knob each,
`MENU_ROW_GAP` being the one a redesign turns. A row that carried vertical margin would be
serving both at once, and since margins don't collapse it would land as one unit at the edges and
two between rows, so shrinking the gap would eat the inset with it. Horizontal inset does stay on
the row: there is one left edge and one right edge, so nothing is doubling up.
- `MenuPage` is also what provides a page's depth, so the popover and the sheet cannot disagree
about what a page is. Every entry point goes through it — root, flyout, pushed sheet page.
- The gap reaches a page's **direct children**. A group of rows wrapped in a `View` of its own drops
out of it; at the current gap of zero there is nothing to lose, but anything above zero wants that
wrapper to carry the page style too.
- The separator is the one row-level thing that asks for vertical space of its own, and now that
rows carry none, that is still one number controlling one gap. It takes no horizontal margin and
the page has no horizontal padding, so the rule runs the full width of the surface.
- Separators use `borderAccent`, the same colour the surface outlines itself with. `border` sits
between `surface1` and `surface2`, which put it within a hair of the hover fill and made
separators disappear against a hovered row.
## When a decision earns a submenu
Put a decision behind a submenu when its options are not the point — the current _value_ is. The
root row then reads as the answer (`Grouping Project ›`) and costs one line instead of one line
per option. A menu whose every option is on screen at once does not survive its third decision.
Independent toggles stay on their page as a checkmark list. A pick-one group can share that page
below a `MenuSeparator`; make selecting the checked row clear it, so "none" doesn't need a row of
its own.
## Gotchas
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
[floating-panels.md](floating-panels.md).
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
+4
View File
@@ -70,6 +70,10 @@ definition, no longer eligible to begin.
- Callers request semantic targets through `panel-store`; they never write shared values.
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
- A focused interactive surface may block panel-opening gestures through
`useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and
unregister when the surface is hidden or unfocused. The blocker never disables gestures that close
an already-open panel.
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
so its injected `collapsable={false}` reaches Android/Fabric.
+166 -1
View File
@@ -1,6 +1,47 @@
# Mobile Testing
## Maestro
## Agent Device
Agent Device `.ad` scripts are the primary mobile E2E format. An agent discovers a working flow interactively, saves the successful commands, then the replay runner executes the same typed plan locally or in CI.
Record a flow while driving the app normally:
```bash
agent-device open sh.paseo.debug \
--platform ios \
--session terminal-author \
--save-script ./packages/app/e2e/mobile/agent-device/terminal.ios.ad
agent-device snapshot -i --session terminal-author
agent-device press 'id="workspace-header-menu-trigger"' --session terminal-author
# Continue the flow and verify its result with wait/get/is/find.
agent-device close --session terminal-author
```
`close` writes the script. Keep selectors based on stable app IDs. Keep assertions as `wait`, `get`, `is`, or `find` commands; screenshots are evidence, not assertions.
Run the Paseo mobile suite:
```bash
npm run test:e2e:mobile
```
The runner uses an isolated Agent Device state directory, verifies or starts Metro for this checkout, prewarms the iOS runner, discovers each script's platform from its `context` header, and cleans its sessions, runner lease, daemon, and any Metro process it started. Attempt results, timings, logs, and failure artifacts go under `.dev/agent-device-artifacts`.
Set `PASEO_MOBILE_E2E_METRO_PORT` when this worktree already has Metro on a non-default port:
```bash
PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
```
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
## Maestro compatibility
Existing Maestro flows live in `packages/app/maestro/`. Agent Device can execute its supported subset with `agent-device test <path> --maestro`, which provides a migration path while `.ad` coverage replaces these flows.
### Existing flows
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
@@ -211,6 +252,25 @@ adb emu gsm call 5551234
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
### Releasing the audio session when idle
Paseo must not hold the OS audio session once it is neither capturing nor playing, or the user's
background music stays paused. On iOS this is not just a "while recording" problem: the
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
it every time the app returns to the foreground — so one dictation turn kills music for the life of
the process, on every open, until the app is force-quit. Android holds
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect.
`createAudioEngine` (`packages/app/src/voice/audio-engine.native.ts`) calls
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
also releases on `OnAppEntersBackground`. The native side re-guards on `isRecording` /
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
wrappers (voice provider + dictation) and only it knows the true state.
This cannot be validated by a JS test — verify on a device: play music, open Paseo, use dictation
once, stop, and confirm the music resumes; then background/foreground the app and confirm it keeps
playing.
## Unistyles + Reanimated
### The crash
@@ -279,6 +339,111 @@ Platform-specific stream edges belong on `StreamStrategy`:
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
## Local iOS device builds
Native changes (anything under `packages/expo-two-way-audio/ios/`, or any new Expo module) **cannot be
tested over Metro or EAS Update**. Those ship JS only. A stale dev-client binary silently lacks the new
native functions, so the feature no-ops and the test proves nothing. Rebuild the binary.
Symptom of a stale dev client: `Cannot find native module 'ExpoDocumentPicker'` at startup, followed by a
cascade of `Route "./X.tsx" is missing the required default export` warnings. The route warnings are not a
router bug — the module-level throw aborts `_layout.tsx` evaluation, so its default export never gets
assigned. One missing native module, N confusing warnings.
**Prerequisite: Xcode needs a signed-in Apple ID for the signing team _with a valid keychain token_.** An
account that merely appears in Xcode → Settings → Accounts is not enough — if its `Xcode-Token` is missing,
the build below fails at code signing and blames a _certificate_ instead. See
[Signing needs a working Apple ID token in Xcode](#signing-needs-a-working-apple-id-token-in-xcode) before
you start, not after it fails.
```bash
cd packages/app
npm --prefix ../.. run build:client
APP_VARIANT=development npx expo prebuild --platform ios
APP_VARIANT=development npx expo run:ios --device
```
`APP_VARIANT=development` is required. The `ios` npm script does not set it, and `app.config.js` defaults
to `production` — so a bare `npm run ios` builds `sh.paseo` and collides with the App Store install instead
of the `sh.paseo.debug` dev client. Ignore prebuild's `--non-interactive is not supported` warning; use
`CI=1` if you need non-interactive.
### Signing needs a working Apple ID token in Xcode
Device builds fail with a trio of errors that all point away from the real cause:
```
error: No Accounts: Add a new account in Accounts settings.
error: Provisioning profile "iOS Team Provisioning Profile: *" doesn't include
signing certificate "Apple Development: <name>".
error: Provisioning profile "iOS Team Provisioning Profile: *" doesn't include
the aps-environment entitlement.
```
**`No Accounts` does not reliably mean no account is configured.** Check the line _above_ it in the build
log before believing it:
```
DVTDeveloperAccountManager: Failed to load credentials for <apple-id>:
Invalid credentials in keychain for <apple-id>, missing Xcode-Token
```
That is the actual failure: the Apple ID is registered in Xcode, but its `Xcode-Token` keychain item is gone
(typically after an Apple ID password change or a keychain reset). Xcode reports it as `No Accounts`, then
falls back to whatever stale profile is cached — and if the machine holds a dev cert newer than that
profile, you get the _certificate_ mismatch as a second-order symptom. Chasing the certificate error is a
dead end.
Verify what is actually configured rather than trusting the message:
```bash
# Accounts Xcode knows about
defaults read com.apple.dt.Xcode DVTDeveloperAccountManagerAppleIDLists
# The credential that is actually missing (error => no token)
security find-generic-password -s "Xcode-Token"
```
Fix: Xcode → Settings → Accounts, re-sign-in to the listed Apple ID (removing and re-adding forces a fresh
token). That restores `-allowProvisioningUpdates`, profile regeneration, and push.
Signing an Xcode-_managed_ profile via `CODE_SIGN_STYLE=Manual` is not a workaround; Xcode rejects it with
`is Xcode managed, but signing settings require a manually managed profile`.
If you need a build on the device before the token is fixed, build unsigned and sign by hand with a cert the
cached profile already embeds (list them with `security cms -D -i <profile>.mobileprovision`, then compare
against `security find-identity -v -p codesigning`):
```bash
xcodebuild ... CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" build
cp <profile>.mobileprovision "$APP/embedded.mobileprovision"
# sign nested frameworks/dylibs first, then the bundle, then:
xcrun devicectl device install app --device <udid> "$APP"
```
This is a stopgap, not a fix: `expo prebuild` regenerates `packages/app/ios/` and discards it. It also
requires stripping entitlements the cached profile lacks (e.g. `aps-environment`), so push is dead in such a
build.
To verify only that native Swift compiles, skip signing entirely — the pods have their own schemes:
```bash
cd packages/app/ios
xcodebuild -workspace PaseoDebug.xcworkspace -scheme ExpoTwoWayAudio \
-sdk iphoneos -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build
```
Confirm the log compiled _your_ file and not a cached copy: the `SwiftCompile` line should reference
`packages/expo-two-way-audio/ios/...`, not a path inside DerivedData.
### Headless launch cannot verify runtime behavior
`xcrun devicectl device process launch` reports `The app terminated with the exit code 0` about a second
after launch when the phone's screen is off — the app is suspended and killed before the JS bundle loads.
This is **not** a build defect. Confirm with a control: launch `com.apple.Preferences` the same way and
watch it exit identically. Headless install and launch prove the binary is valid and that startup reaches
RN init; anything about on-screen behavior needs a human holding the phone.
## iOS Simulator
```bash
+1 -1
View File
@@ -12,7 +12,7 @@ The development workflow is shifting from manually editing files to orchestratin
Freedom and flexibility. Every design decision follows from this:
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
- **Respectful** - No telemetry, no forced cloud, no forced accounts
+59
View File
@@ -0,0 +1,59 @@
# Protocol Compatibility
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
Two contracts follow from it.
## The protocol contract: always compatible
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
- New fields are `.optional()` with a sensible default.
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
- Wire schemas are pure structural declarations. No `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas — normalization happens in an explicit pass after validation. The reason is in [protocol-validation.md](protocol-validation.md): inbound validators are generated, and the generator only compiles pure schemas.
- Plain `z.union()` is forbidden when every branch shares a literal tag. Use `z.discriminatedUnion()`.
- `.default()` belongs on primitive leaves only, never on item schemas inside large arrays or big inbound containers.
Two questions to ask before you commit a schema change:
1. Does a six-month-old app still parse this message?
2. Does a six-month-old daemon still send something this app accepts?
If you can't answer both with yes, the change isn't done.
Schemas live in `packages/protocol/src/messages.ts`. New RPC names follow [rpc-namespacing.md](rpc-namespacing.md).
## The feature contract: per feature, gated once
Features don't have to work across versions. A new feature usually needs a new daemon capability, and old daemons don't have it.
The app checks for the capability and either runs the feature or tells the user to update the host.
- **No fallback paths.** Don't build a degraded version of the feature for old daemons. Don't fan out across legacy RPCs to simulate a capability that isn't there. The user updates or doesn't get the feature.
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
```ts
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
```
`rg "COMPAT\("` is the full cleanup backlog, so:
- One tag per shim, at the site that has to be deleted.
- Give it a name, a version, and a removal condition or date. Six months out is the usual default.
- Never bury compatibility in an untagged `??` fallback or an optional-chain tunnel. Untagged back-compat never gets removed, because nobody can find it.
When a tag's condition is met, delete the shim and the tag in the same change.
## QA
Tests don't fully cover compatibility. If you touched `packages/protocol`, say in the pull request why an older app still parses your message and why an older daemon still satisfies your app. See [qa.md](qa.md).
+54 -3
View File
@@ -2,6 +2,41 @@
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
## Provider-native session options
`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The
names and nesting are the provider's native contract; options are not portable between providers.
Paseo validates the object with the selected provider's strict schema before constructing a session.
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
This Paseo version accepts these keys:
- **Codex:** `approval_policy`, `sandbox_mode`,
`sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`,
`web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may
contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`,
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
`permissions.{allow,ask,deny}` and sandbox settings. See the
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
and [Claude settings reference](https://code.claude.com/docs/en/settings).
- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule
object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`,
`external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`,
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
application policy, not an OS sandbox.
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
@@ -12,6 +47,8 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
ACP permission options are rendered as ordered actions and Paseo returns the selected option's exact `optionId`. Agents can therefore encode a single-choice question as multiple options of the same allow kind. Auto-accept does not resolve those chooser requests; they always wait for the user.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
@@ -42,11 +79,15 @@ OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add`
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
@@ -66,6 +107,8 @@ The daemon keeps provider snapshots per resolved working directory, with a separ
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
@@ -458,3 +501,11 @@ Tests use `isProviderAvailable(provider)` to skip when the binary or credentials
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
**Session-scoped cancellation needs a stop boundary inside the provider.** Some agents cancel the whole session rather than one turn — OpenCode's `session.abort` is the example. A cancel that is still in flight when the next run starts will kill that replacement run, which is what makes "stop, then immediately prompt again" (`replaceRunning`, `notifyOnFinish` wakes, schedules) flaky. Own this in the provider session, not in `AgentManager`:
- Model the stop as an explicit `stopping` turn-state variant carrying the canceled run's terminal and the cancellation the caller is still owed. Pressing Stop again retries the stop already in progress rather than opening a second one; never fire a detached retry, it will outlive its boundary.
- **Scope the cancel settlement the way the provider scopes the cancel.** If cancellation is session-scoped, so is its settlement: track it on the session, accumulating every request issued, and let it outlive the stop that issued it. A request still in flight lands on the runner whenever the server gets to it — however many stops have come and gone since. Scoping it to the current stop looks right and quietly drops older requests from the gate. Only the newest may hold the gate closed, since recovering from a failed cancel is what pressing Stop again is for.
- Gate the operations **the daemon issues** (prompt, slash command, summarize) on both the terminal and cancel settlement. Permission and question responses are not runner operations and must stay outside the gate, or an auto-approve deadlocks the stop. Runs the _provider_ starts on its own — plugin or autonomous wakes — are observed, not gated: the daemon does not choose when they begin, and holding their events back does not protect them from a cancel already in flight, it only hides a run that may already be dead.
- Fail closed: if the cancel never succeeded you never proved the run stopped, so refuse new runs until the next Stop issues a fresh cancel. `AgentManager` already turns a rejected `interrupt()` into a refused cancel.
- Suppress the canceled run's residue only until its authoritative terminal. Anything the provider publishes after that terminal is a new run by construction and must take the normal live path — buffering it and replaying it later is how autonomous/plugin wakes get lost.
+77
View File
@@ -0,0 +1,77 @@
# QA
QA is the main bottleneck of Paseo's product development.
The bar is four questions:
1. Does it work well?
2. Does it regress anything else?
3. Was it tested on every platform it affects?
4. Is there automated coverage, and is that coverage real?
Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed.
## Evidence
Evidence is something someone else can look at:
- The commands you ran, pasted with their output
- The tests you added and their results
- Before and after screenshots
- A video of the interaction
- Logs, requests, responses
Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it.
## Does it work well
A feature that works but stutters, shifts the layout, or misaligns by two pixels is not done.
For UI, that means matching the design system in visuals, in interaction, and in performance. [design.md](design.md) is the reference.
The two things that go wrong most often:
- **Layout shift.** Content that jumps as data arrives, a row that resizes when a badge appears, a panel that reflows on first render. Watch the change on a slow connection and on first load, not just after everything is warm.
- **Alignment.** Things align to their glyphs, not to their touch targets. See the alignment rules in [design.md](design.md).
## Does it regress anything else
Paseo is composable by design, which means your change sits next to features you didn't touch. Open the surfaces around it. A change to the agent list affects archive, subagents, and tabs; a change to git actions affects worktrees and the checkout flow.
Performance is part of this. The app is Expo React Native, not a web app in a native shell. You are not writing CSS, styles resolve differently, and performance characteristics differ per platform. What feels instant in a desktop dev build can be visibly slow on a phone.
If your change touches a hot path such as the terminal, the message list, or git polling, submit before and after numbers. [terminal-performance.md](terminal-performance.md) has the terminal pipeline and its benchmarks, and [development.md](development.md) covers renderer and React profiling.
## Every platform it affects
Your code does not only run on the platform you tested it on. The same app ships to iOS, Android, browser web, and Electron on macOS, Windows, and Linux, and the daemon runs on all three desktop operating systems plus Docker.
You aren't expected to own every device. You are expected to say what you covered:
| Platform | Tested | Notes |
| --------------- | ------ | ----- |
| iOS | | |
| Android | | |
| Web | | |
| Desktop macOS | | |
| Desktop Windows | | |
| Desktop Linux | | |
Install what you reasonably can. An iOS simulator and an Android emulator cover most of the gap on a single machine, see [development.md](development.md) and [android.md](android.md).
For the rules about which code runs where, read the platform gating section in [CLAUDE.md](../CLAUDE.md). The recurring traps have their own docs: [hover.md](hover.md), [unistyles.md](unistyles.md), [floating-panels.md](floating-panels.md), [mobile-panels.md](mobile-panels.md), [expo-router.md](expo-router.md).
App and daemon versions also drift, in both directions. That has its own contract: [protocol-compatibility.md](protocol-compatibility.md).
## Automated coverage that means something
Tests are evidence only when they exercise the real thing.
- A bug fix needs a regression test that fails on the old code for the reported reason.
- A feature needs tests that go through the same interface a user or a caller uses.
- Web flows need an actual Playwright run, not a mocked-out approximation of one.
- Agent provider work needs manual verification against the real provider by someone who can tell that it worked. A fixture and a mock prove the fixture parses, nothing more.
Tests that mock away the behavior, assert on internals, or pass against the broken code claim coverage that isn't there.
[testing.md](testing.md) is the standard, including how to run suites without freezing your machine. For driving a real daemon in a test, see [ad-hoc-daemon-testing.md](ad-hoc-daemon-testing.md). For mobile flows, [mobile-testing.md](mobile-testing.md). For Electron screenshots, [browser-capture-harness.md](browser-capture-harness.md).
+3 -32
View File
@@ -19,23 +19,22 @@ The first carves shipped as **deep modules with a narrow Host seam**, not the `d
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
- **ScheduleSession** (`session/schedule/schedule-session.ts`, seam `ScheduleSessionHost`): scheduled-agent request handling.
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
## Slice ordering (least-coupled first)
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The safer first cut is **provider-catalog** (one shared collaborator + injected predicates).
| # | Slice | Effort | Risk |
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
| 2 | ProviderCatalogController | M | medium |
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
| 4 | GitCheckoutController | L | medium |
@@ -47,34 +46,6 @@ The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verific
---
## Slice 0 — Test net + disjointness tripwire (prerequisite)
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
Write RED-then-GREEN against the **current in-place** Session:
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
- one `schedule/*` and one `loop/*` round-trip.
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
- a handler that **rejects async** emits the SAME pair.
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
---
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
---
## Slice 2 — ProviderCatalogController
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
+122 -52
View File
@@ -9,19 +9,17 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
if stale package-runner pins are intentional, say so explicitly, otherwise run
`npm run acp:version-drift:update` and commit the updated catalog
- classify the previous-stable-to-`HEAD` diff as patch or minor, then show the
- resolve the release source to one commit and confirm that commit's existing CI is green
- classify the diff from the previous stable to the release source as patch or minor, then show the
target version and rationale to the user
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
**Go-ahead** (user says "go ahead"):
- commit the approved changelog
- run the release
- commit the approved release inputs locally
- run the release, which publishes npm and pushes the prepared branch and tag
- create the release heartbeat immediately and babysit it to completion
Rules that apply to both steps:
@@ -31,12 +29,43 @@ Rules that apply to both steps:
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Release source and CI
The default release source is `origin/main`. Fetch `origin`, then record the
resolved commit. The default release checkout is a clean local `main` whose
`HEAD` equals `origin/main`.
An explicit user instruction can select another ref, such as a hotfix commit or
tag. Resolve that ref once and apply every source, diff, and CI check to that
commit instead of `origin/main`.
Before making release-preparation commits, confirm the existing CI run for the
resolved commit is green. Pending CI is watched to completion. Release
preparation then stays local through the changelog, any explicitly requested ACP
catalog update, lockfile preparation, and the version commit. After approval,
commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
## ACP catalog updates
ACP catalog work enters a release through an explicit user request:
- **Check ACP drift** — run `npm run acp:version-drift:check`. When drift exists,
run `npm run acp:version-drift:update`, verify the catalog, and include the
update in the local release-preparation commits.
- **Update ACP** — run `npm run acp:version-drift:update`, verify the catalog, and
include the update in the local release-preparation commits.
The release authorization covers the requested ACP commit. It ships in the same
release push as the changelog and version commit.
## Two paths
There are two supported ways to ship from `main`:
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
@@ -48,8 +77,9 @@ pointers. The npm invariant is:
## Release version decision
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
diff. The highest-impact change determines the version:
Every fresh release starts by classifying the full diff from the previous
stable to the resolved release source. The highest-impact change determines the
version:
- **Minor** — a user would experience the release as a significant upgrade. This
includes substantial new workflows, providers, forges, platforms, integrations,
@@ -72,7 +102,7 @@ as described in **Fixing a failed release build**.
Before running any stable release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
@@ -82,7 +112,7 @@ npm run release:patch
npm run release:minor
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
@@ -129,11 +159,11 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Betas carry a changelog entry.** Beta users read release notes, so each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that `Release Notes Sync` mirrors into the prerelease body on the tag push. The entry is intermediary: promotion overwrites it in place with the final stable entry, so no `-beta.N` heading is ever left behind. See the Changelog policy section.
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
Use the beta path when you need to:
@@ -238,9 +268,11 @@ iOS and Android store builds are not in `.github/workflows`. They are triggered
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives Android `versionCode` and iOS `buildNumber` from the package version as `major * 1_000_000 + minor * 1_000 + patch`, ignoring prerelease metadata. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut a new patch instead of relying on EAS remote auto-increment.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
### Watching mobile builds from the terminal
@@ -289,11 +321,30 @@ Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS re
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
### Babysitting mobile after a release
## Release completion and heartbeat
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
A release is **in progress** after npm publication and tag push. Report it as
**shipped** only after every applicable build, publication, asset, manifest, and
store submission passes the completion checklist.
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
Immediately after every beta, stable, or promotion tag push, create a heartbeat
that resumes the release in the current conversation. Create it automatically
with `create_heartbeat`. The heartbeat owns the release until it either reaches
the completion checklist or finds a failure that needs new user authority.
Each heartbeat checks the release tag commit, all GitHub Actions runs for the
release branch and tag, npm dist-tags, the GitHub Release body and assets,
desktop updater manifests, the published Docker image, and the applicable EAS
workflow. Inspect the GitHub Release itself and confirm that the macOS, Linux,
Windows, and Android APK assets are present along with the channel manifests
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
For stable releases, also confirm every required mobile build, upload, store
submission, and review-submission job for the release commit. For betas, confirm
the beta EAS workflow completed its TestFlight distribution and Beta App Review
path. Delete the heartbeat only after every applicable checklist item passes,
then report the release as shipped.
Pattern:
@@ -301,13 +352,16 @@ Pattern:
// mcp__paseo__create_heartbeat arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"cron": "*/15 * * * *",
"maxRuns": 8, // covers ~2h of build + store-submission window
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
"cron": "*/10 * * * *",
"timezone": "UTC",
"maxRuns": 120,
"expiresIn": "24h",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
}
```
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
Run an immediate status check after creating the heartbeat. The heartbeat handles
later transitions and stops itself when the release is complete.
## Release notes on GitHub
@@ -315,10 +369,12 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The website download page defaults to GitHub's latest published **stable** release.
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
## Fixing a failed release build
@@ -399,17 +455,16 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
## Changelog policy
- `CHANGELOG.md` includes stable releases and the current beta line.
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- Each subsequent beta updates that same top entry in place — bump the heading (`0.1.60-beta.1` → `0.1.60-beta.2`) and fold in whatever else landed.
- Stable promotion updates that same entry in place one last time: heading to `0.1.60`, date to the promotion day.
- One entry per version line. The `-beta.N` heading is intermediary — overwrite it, never append. Don't leave stale `-beta.N` entries behind and don't create a duplicate entry per beta.
- It always covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag.
- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry.
- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series.
## Changelog ownership
- **The agent running the release writes the changelog entry — beta or stable.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
- Draft the entry from the previous-stable-to-`HEAD` diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it. Each beta refreshes the same entry; promotion refreshes it one last time from the full previous-stable-to-`HEAD` diff.
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
## Changelog voice
@@ -430,10 +485,8 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
- **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.
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
@@ -476,7 +529,7 @@ Rules:
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.
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
## Changelog ordering
@@ -490,51 +543,68 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
Review the diff between the latest release tag and `HEAD`. Focus on:
Review the diff between the latest release tag and the resolved release source. Focus on:
1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
3. **Regressions** — anything that looks like it could break existing functionality.
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
## Changelog scope
The changelog always covers **previous-stable-to-`HEAD`**, beta and stable alike:
Changelog scope follows the release being described:
- **Beta release**: the entry covers `previous stable tag → HEAD`. Update the current in-place beta entry; don't start a fresh one per beta.
- **Stable promotion**: the same entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
- **First beta**: `previous stable tag → release source`
- **Later beta**: `previous beta tag → release source`
- **Direct stable release**: `previous stable tag → release source`
- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source`
Betas are checkpoints along the way; the entry is the single record for the jump from one stable version to the next, and beta users read it in the meantime.
Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any release command
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
- [ ] EAS `Release Mobile` workflow for the same tag is green
- [ ] EAS iOS `build_ios` completes for the same tag
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
+5
View File
@@ -53,6 +53,11 @@ Claude hook mapping:
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
while terminal activity is working clears the activity without finished attention. The same
fallback applies to every hooked terminal agent; exact input matching excludes escape sequences and
pasted content, while later provider idle events remain authoritative.
Codex hook mapping:
- `UserPromptSubmit` → `running`
+2 -1
View File
@@ -25,12 +25,13 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
## Measuring
- **Node-only benchmark (fast iteration, server pipeline):** `npx tsx scripts/benchmark-terminal-latency.ts`. Boots an isolated daemon (fresh `PASEO_HOME`, random port — never 6767), measures echo latency percentiles, burst jitter, and snapshot counts under ramped mock-agent load. Writes JSON to `/tmp/paseo-terminal-bench/`. Healthy numbers (2026-06): echo p50 ~2.3ms, p95 ~3.3ms, a 2MB burst fully streamed with `snap=0`.
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` —
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
`packages/app/e2e/browser/terminal-performance.spec.ts` and `packages/app/e2e/browser/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age,
queue wait, execution time, failures, timeouts, and top operations),
+17 -8
View File
@@ -124,14 +124,14 @@ PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
npm run build:desktop -- --publish never --linux --x64 --dir
```
### Browser tab bridge regression
### Desktop browser regression
The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
The desktop browser E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check:
Run it locally with the same command owned by the Ubuntu `desktop-tests` required check:
```bash
npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
```
## Test organization
@@ -154,7 +154,7 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Browser Playwright specs live in `packages/app/e2e/browser/`. Desktop Playwright and real-Electron E2E live in `packages/desktop/e2e/`. Harness code shared by both suites lives in `packages/app/e2e/support/`; neither suite may place specs there. App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default browser project ignores that suffix so CI does not need provider credentials.
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
@@ -175,9 +175,18 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
- Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence.
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
## Pull-request test routing
PR checks are routed by the behavior each suite proves, using `.github/ci-paths.yml`. A package does not inherit every test suite of its runtime consumers: app changes do not run CLI or Electron-wrapper tests, and protocol changes do not run every package that imports the protocol. Cross-package static compatibility belongs to `typecheck`; full integration coverage runs after merge on main and in manual CI runs.
Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name.
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer, real-Electron, and packaged-app coverage together in the Ubuntu leg. CLI-side Hub changes select one focused test inside the existing required server jobs. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
## Agent authentication in tests
+125 -21
View File
@@ -5,9 +5,11 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is:
The invariants are:
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
> A continuously subscribed client applies every committed row in order. Opening or resuming an
> agent establishes the daemon's current tail in one bounded request, with older history reachable
> through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
@@ -25,17 +27,26 @@ Client heartbeat reports presence:
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
## Catch-up is paged but complete
## Gap recovery is paged but complete
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon
responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap
recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
user-driven by scrolling upward.
Reaching the history-start threshold loads one older page and preserves the visible content anchor.
Cursor progress does not trigger another page. The user must leave and return to the threshold unless
the anchored page still leaves the viewport at history start, as with short or compacted content; in
that case pagination continues as one loading operation until the page fills the viewport or history
is exhausted.
## Durable item anchors
@@ -47,9 +58,22 @@ The daemon validates that the epoch is current and the exact source sequence sti
## Resume behavior
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail
page.
Focus alone does not mutate timeline state; the tail response is compared with the local
authoritative range first.
When a client resumes without a cursor, it fetches the latest tail page.
- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
that cursor are applied. Already-covered rows are not replayed.
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
local submissions; it never retains two discontiguous canonical ranges.
The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
history start; a response requested from a pre-replacement range is stale and is discarded.
## Client replica lifetime
@@ -61,17 +85,36 @@ recomposition while the runtime still owns the same directory snapshot and timel
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
older-history availability, authority status, or sync generation because those facts would describe
the complete source dataset rather than the truncated display dataset.
Restoring that cache produces a painted timeline: the items may render immediately, but the first
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
items, range, and older-history availability. Live rows received between cache paint and that tail
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
reconciled with the authoritative tail and subsequent catch-up.
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
replacement keeps only positioned rows newer than the page it installs, while unresolved local
user presentations identify themselves by having client identity without a timeline position. This
prevents a page from duplicating rows it already covers without coupling display continuity to the
shorter-lived submission registry. Unreconciled local presentations are not persisted in the durable
replica cache.
## Selective and legacy delivery
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
After grace has expired, revisiting a retained timeline displays its cached state immediately and
authoritative catch-up advances it to the current tail.
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
an evicted retained timeline displays its cached state immediately while authoritative catch-up
advances it to the current tail.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
@@ -87,14 +130,75 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
where the user submitted it. Other canonical rows are applied after the already-present timeline
instead of relocating visible user messages around newly fetched history.
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
send-now, and the automatic queue drain in `HostRuntime` — goes through
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
footer, and permanently drops attachments because the daemon does not echo them back.
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
limited to the dated compatibility path for daemon timelines created before that field existed.
A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation:
its stable identity, text, timestamp, images, and attachments do not change when the provider
acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape.
The transaction registry records two independent settlement facts: canonical acknowledgement and RPC
settlement. Canonical acknowledgement retires optimistic activity immediately. When both facts are
known, whichever arrives second deletes the record. A canonical acknowledgement that arrives first
prevents a later transport error from rolling back a prompt already observed.
The daemon's accepted response waits for the correlated run start and guarantees that the canonical
submitted row has been recorded. It publishes the accepted turn's liveness before that row, so the
client applies authoritative activity before canonical acknowledgement retires optimistic activity.
Timeline render batching does not delay lifecycle application. Directory status never settles a
submission. Overlapping sends settle independently rather than collapsing to one newest pending
message.
Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted
prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
row is recorded before handler output. The app tracks submission transactions only for hosts with this
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
rejection.
Turn activity has one client-side replica. Lifecycle events can attach `turnId`, and agent snapshots can
expose `activeTurn: { turnId, startedAt } | null`. An identified terminal cannot close a different
identified turn; an unnamed legacy terminal can close the current turn. Snapshots, stream events,
cancellation requests, and every visible surface update the same per-agent record. The snapshot covers
both user-started foreground turns and autonomous provider turns;
foreground control ownership remains a separate daemon concern. Cancellation request identity is stored
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time
comes only from turn liveness, never from submission records or whichever timeline rows happen to be
mounted.
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
the wire. A correlated provider echo records the provider's native identity internally without
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
the provider boundary. Daemon-handled prompts follow the same identity rule.
Content matching is limited to the dated compatibility path for daemon timelines created before
that field existed. Canonical ingestion may match only an explicit unreconciled local candidate;
the draft-create handoff is the one boundary that also permits the legacy canonical twin to have
arrived first. Generic reducers and consumers do not reimplement message identity matching.
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
rows because a provider may never echo them. A known epoch change or rewind replaces history and
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
acknowledged by the provider, and no other local row, crosses that destructive boundary. A cold
reset without an existing epoch is destructive because the client has no continuity anchor.
Tail rows are positioned history, so an unmatched local presentation is appended after the
canonical replacement rather than ordered by timestamps from different machines. The head is a
live overlay: cursorless items stay there during continuity replacement until canonical positions
arrive, while a destructive replacement retains only active submission transactions.
Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and
payload while taking the canonical row's ordered position. If a live assistant head is the
canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes.
## Relevant code
+8 -3
View File
@@ -129,7 +129,12 @@ This mistake once produced tens of thousands of warnings from retained sidebar r
## 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.
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the `style` prop Unistyles registers. How badly that bites depends on who owns the component:
- **A remapped React Native `ScrollView`** still picks the style up — the Babel plugin rewrites the import and the factory tracks `contentContainerStyle` alongside `style`. The failure mode here is staleness: on first mount it can paint with the theme that was active at mount time and never repaint. The upstream tutorial calls this out in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
- **A third-party scroller** — `BottomSheetScrollView`, `FlashList`, anything Unistyles never rewrote — drops the style entirely on web. Nothing throws and the value looks right in JS, but the class Unistyles emitted is never applied, so the padding, gap, or background is simply absent. `AdaptiveModalSheet` shipped its compact sheet content flush to the screen edges for exactly this reason while the desktop card, which uses a real `ScrollView`, looked correct.
The second case is why the wrapper-`View` fix below is not optional styling advice. A themed inset belongs on a `View` you own; a third-party scroller only gets theme-free layout.
Avoid this pattern when the style depends on the theme:
@@ -144,7 +149,7 @@ const styles = StyleSheet.create((theme) => ({
}));
```
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.
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 reports 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`.
@@ -340,7 +345,7 @@ Gotchas:
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
## Debugging
+11 -6
View File
@@ -26,17 +26,22 @@
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
isLinux = nixpkgs.lib.elem system [
"x86_64-linux"
"aarch64-linux"
versionParts = pkgs.lib.splitString "." paseo.version;
sourceRevision = if self ? revCount && self.revCount != null then self.revCount else 0;
buildRevision = sourceRevision - (sourceRevision / 10000) * 10000;
desktopBuildVersion = pkgs.lib.concatStringsSep "." [
(builtins.elemAt versionParts 0)
(builtins.elemAt versionParts 1)
(toString buildRevision)
];
in
{
default = paseo;
paseo = paseo;
}
// nixpkgs.lib.optionalAttrs isLinux {
desktop = pkgs.callPackage ./nix/desktop-package.nix { inherit paseo; };
desktop = pkgs.callPackage ./nix/desktop-package.nix {
inherit paseo;
buildVersion = desktopBuildVersion;
};
}
);
+155 -54
View File
@@ -5,34 +5,48 @@
nodejs_22,
python3,
makeWrapper,
autoPatchelfHook,
copyDesktopItems,
makeDesktopItem,
electron,
libuv,
buildVersion,
# Reuse the daemon's prebuilt npm-deps FOD. Same lockfile, same content —
# without this, the desktop drv produces a separately-named store path
# (`paseo-desktop-<v>-npm-deps`) and refetches the entire registry. Override
# the upstream hash via `paseo.override { npmDepsHash = "..."; }`.
paseo,
}:
buildNpmPackage rec {
buildNpmPackage {
pname = "paseo-desktop";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter =
path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
filter = path: type: let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude mobile-only platform code (we only need the web/electron build)
!(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
# Website is unrelated to the desktop app
&& !(lib.hasPrefix "/packages/website" relPath)
# Documentation, CI definitions and agent/editor configuration. None of
# these reach the build, but every one of them is part of `src`, so a
# docs-only or workflow-only commit currently invalidates the whole
# desktop derivation and pays for a full Expo export to produce a
# byte-identical result.
&& !(lib.hasPrefix "/docs" relPath)
&& !(lib.hasPrefix "/.github" relPath)
&& !(lib.hasPrefix "/.agents" relPath)
&& !(lib.hasPrefix "/.claude" relPath)
&& !(lib.hasPrefix "/.codex" relPath)
&& !(lib.hasPrefix "/docker" relPath)
# Top-level prose only (README, CHANGELOG, AGENTS...). Deeper markdown is
# not necessarily documentation: skills/*/SKILL.md is a runtime file the
# installPhase copies into the output.
&& builtins.match "/[^/]+\\.md" relPath == null
# Test fixtures and build artifacts
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
@@ -48,16 +62,23 @@ buildNpmPackage rec {
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
npmRebuildFlags = ["--ignore-scripts"];
nativeBuildInputs = [
python3 # for node-gyp (node-pty)
makeWrapper
copyDesktopItems
nativeBuildInputs =
[
python3 # for node-gyp (node-pty)
]
++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
makeWrapper
copyDesktopItems
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
libuv
stdenv.cc.cc.lib # libstdc++ for sherpa-onnx prebuilt binaries
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
dontNpmBuild = true;
env = {
@@ -82,61 +103,125 @@ buildNpmPackage rec {
# Expo web export for the Electron renderer
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
# Desktop main process (tsc only — NOT electron-builder)
# Desktop main process
npm run build:main --workspace=@getpaseo/desktop
${lib.optionalString stdenv.hostPlatform.isDarwin ''
# Let electron-builder create the native bundle layout (including helper
# app names and bundle identifiers), but source Electron from nixpkgs
# instead of downloading a release at build time. electron-builder edits
# the copied helper plists, so stage a writable distribution rather than
# pointing it directly at the read-only Nix store.
electron_dist="$NIX_BUILD_TOP/electron-dist"
mkdir -p "$electron_dist"
cp -R ${electron}/Applications/Electron.app "$electron_dist/"
chmod -R u+w "$electron_dist/Electron.app"
(
cd packages/desktop
# The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \
../../node_modules/.bin/electron-builder \
--config electron-builder.yml \
--dir \
--mac \
--publish never \
--config.electronDist="$electron_dist" \
--config.buildVersion=${lib.escapeShellArg buildVersion} \
--config.mac.identity=null \
--config.mac.hardenedRuntime=false \
--config.mac.notarize=false
)
''}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/share/paseo-desktop $out/bin
mkdir -p $out/bin
# Preserve the monorepo layout so main.js's dev-mode path resolution
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
# without patching: invoked unpackaged via `electron path/to/main.js`,
# `app.isPackaged` is false, so these relative paths are used.
#
# Copy the entire packages/ tree (not just built artifacts) because npm
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
# Missing any workspace package leaves dangling symlinks and fails the
# noBrokenSymlinks output check. The cleanSourceWith filter above already
# drops the big platform-specific things (android/ios, website, tests).
cp package.json $out/share/paseo-desktop/
cp -a packages $out/share/paseo-desktop/
cp -a node_modules $out/share/paseo-desktop/
${lib.optionalString stdenv.hostPlatform.isLinux ''
mkdir -p $out/share/paseo-desktop
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Materialize only the desktop and daemon runtime graphs. Copying the
# complete monorepo used to ship every build-time dependency (including
# Electron, Expo tooling, and cross-platform builder binaries), making the
# desktop output larger than 2 GiB.
PASEO_TRACE_DESKTOP=1 node scripts/trace-daemon.mjs > desktop-files.txt
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
while IFS= read -r path; do
[ -z "$path" ] && continue
mkdir -p "$out/share/paseo-desktop/$(dirname "$path")"
cp -a "$path" "$out/share/paseo-desktop/$path"
done < desktop-files.txt
# Launcher wraps nixpkgs electron.
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
# `security.wrappers` via a NixOS module for users who want the sandbox.
#
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
# exist here). Point it at the `paseo://` protocol handler instead, which
# serves from `__dirname/../../app/dist` (our install layout matches).
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
--add-flags "--no-sandbox" \
--set EXPO_DEV_URL "paseo://app/"
# Keep the same unpackaged monorepo layout expected by main.js.
cp package.json $out/share/paseo-desktop/
mkdir -p $out/share/paseo-desktop/packages/app
cp -a packages/app/dist $out/share/paseo-desktop/packages/app/
copyDesktopItems
for runtime_path in \
packages/desktop/dist/main.js \
packages/desktop/dist/preload.js \
packages/desktop/dist/features/browser-keyboard/guest-preload.js \
packages/desktop/package.json; do
if [ ! -e "$out/share/paseo-desktop/$runtime_path" ]; then
echo "desktop runtime trace omitted $runtime_path" >&2
exit 1
fi
done
if [ -e $out/share/paseo-desktop/node_modules/electron ]; then
echo "desktop runtime trace included npm Electron" >&2
exit 1
fi
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
# Electron derives Wayland's toplevel app_id from the package name in the
# app root it launches. Point it at a one-file app named "paseo-desktop"
# so shells can match the window to the desktop entry and hicolor icon.
mkdir -p $out/share/paseo-desktop/electron-app
printf '%s\n' "{ \"name\": \"paseo-desktop\", \"version\": \"$version\", \"main\": \"index.js\" }" \
> $out/share/paseo-desktop/electron-app/package.json
printf '%s\n' 'require("../packages/desktop/dist/main.js");' \
> $out/share/paseo-desktop/electron-app/index.js
# Chromium's setuid sandbox cannot live in the immutable Nix store.
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/electron-app" \
--add-flags "--no-sandbox" \
--add-flags "--class=paseo-desktop" \
--set EXPO_DEV_URL "paseo://app/" \
--set CHROME_DESKTOP "paseo-desktop.desktop"
copyDesktopItems
''}
${lib.optionalString stdenv.hostPlatform.isDarwin ''
app="$(find packages/desktop/release -maxdepth 3 -type d -name Paseo.app -print -quit)"
if [ -z "$app" ]; then
echo "electron-builder did not produce Paseo.app" >&2
exit 1
fi
mkdir -p "$out/Applications"
cp -R "$app" "$out/Applications/Paseo.app"
ln -s ../Applications/Paseo.app/Contents/MacOS/Paseo "$out/bin/paseo-desktop"
''}
runHook postInstall
'';
desktopItems = [
desktopItems = lib.optionals stdenv.hostPlatform.isLinux [
(makeDesktopItem {
name = "paseo-desktop";
desktopName = "Paseo";
@@ -144,8 +229,24 @@ buildNpmPackage rec {
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = ["Development"];
startupWMClass = "paseo-desktop";
})
# Hidden alias entry. Which of the two names Electron ends up publishing as
# the Wayland app_id depends on the Electron version: 41 uses the app-root
# package.json `name` ("paseo-desktop"), 38 uses the runtime app name that
# main.ts sets ("Paseo"). Ship a NoDisplay entry for the second spelling so
# the icon resolves either way without a duplicate launcher item.
(makeDesktopItem {
name = "Paseo";
desktopName = "Paseo";
genericName = "AI Coding Agents";
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = [ "Development" ];
startupWMClass = "Paseo";
noDisplay = true;
})
];
@@ -154,6 +255,6 @@ buildNpmPackage rec {
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux;
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
-1
View File
@@ -230,7 +230,6 @@ in
'';
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment (
+1 -1
View File
@@ -1 +1 @@
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI=
+17 -1
View File
@@ -37,6 +37,21 @@ buildNpmPackage rec {
&& !(lib.hasPrefix "/packages/website/public" relPath)
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
# Documentation, CI definitions and agent/editor configuration. None of
# these reach the build. Excluding them here also matters for the desktop
# derivation, which inherits this package's npmDeps: leaving them in makes
# a docs-only commit produce a new npm-deps .drv, and so a new desktop
# .drv, and so a full rebuild for a byte-identical result.
&& !(lib.hasPrefix "/docs" relPath)
&& !(lib.hasPrefix "/.github" relPath)
&& !(lib.hasPrefix "/.agents" relPath)
&& !(lib.hasPrefix "/.claude" relPath)
&& !(lib.hasPrefix "/.codex" relPath)
&& !(lib.hasPrefix "/docker" relPath)
# Top-level prose only (README, CHANGELOG, AGENTS...). Deeper markdown is
# not necessarily documentation: skills/*/SKILL.md is a runtime file the
# daemon's trace script copies into the output.
&& builtins.match "/[^/]+\\.md" relPath == null
# Exclude test fixtures and debug files
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
@@ -114,9 +129,10 @@ buildNpmPackage rec {
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
# Keep Paseo's runtime mode separate from NODE_ENV, which belongs to spawned agents.
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
--set NODE_ENV production
--set PASEO_NODE_ENV production
# Create wrapper for the CLI
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
+1212 -609
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.4",
"version": "0.3.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -39,6 +39,7 @@
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
@@ -0,0 +1,25 @@
name: Release iOS Beta
on:
push:
tags:
- "v*-beta.*"
jobs:
build_ios:
name: Build iOS beta
type: build
params:
platform: ios
profile: production
distribute_ios_beta:
name: Distribute iOS beta
needs: [build_ios]
type: testflight
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
external_groups:
- Paseo Beta
submit_beta_review: true
+11 -37
View File
@@ -1,9 +1,13 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
const buildProfile = isFdroidBuild
? {
@@ -15,7 +19,6 @@ const buildProfile = isFdroidBuild
cameraPlugins: [],
fdroidPlugins: [withFdroidAutolinking],
notificationPlugins: [],
updates: { enabled: false },
}
: {
androidPermissions: [
@@ -44,33 +47,8 @@ const buildProfile = isFdroidBuild
},
],
],
updates: {},
};
function getNativeBuildVersionCode(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
if (!match) {
throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`);
}
const [, majorText, minorText, patchText] = match;
const major = Number(majorText);
const minor = Number(minorText);
const patch = Number(patchText);
if (minor > 999 || patch > 999) {
throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`);
}
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
}
return versionCode;
}
function resolveSecretFile(params) {
const fromEnv = process.env[params.envKey];
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
@@ -113,25 +91,18 @@ const variants = {
};
const variant = variants[appVariant] ?? variants.production;
const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version);
const nativeReleaseVersion = getNativeReleaseVersion(pkg.version);
export default {
expo: {
name: variant.name,
slug: "voice-mobile",
version: pkg.version,
version: nativeReleaseVersion.appVersion,
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
newArchEnabled: true,
runtimeVersion: {
policy: "appVersion",
},
updates: {
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
...buildProfile.updates,
},
ios: {
supportsTablet: true,
infoPlist: {
@@ -142,7 +113,7 @@ export default {
...(variant.googleServiceInfoPlist
? { googleServicesFile: variant.googleServiceInfoPlist }
: {}),
buildNumber: String(nativeBuildVersionCode),
buildNumber: nativeReleaseVersion.iosBuildNumber,
},
android: {
adaptiveIcon: {
@@ -156,7 +127,7 @@ export default {
usesCleartextTraffic: true,
permissions: buildProfile.androidPermissions,
package: variant.packageId,
versionCode: nativeBuildVersionCode,
versionCode: nativeReleaseVersion.androidVersionCode,
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
},
web: {
@@ -168,6 +139,7 @@ export default {
},
plugins: [
"expo-router",
[withAndroidAsyncStorageSize, 10],
...buildProfile.cameraPlugins,
[
"expo-splash-screen",
@@ -202,6 +174,7 @@ export default {
},
],
...buildProfile.fdroidPlugins,
...(isProfileBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -210,6 +183,7 @@ export default {
},
extra: {
fdroidBuild: isFdroidBuild,
profileBuild: isProfileBuild,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
@@ -1,34 +0,0 @@
// "00-" prefix is intentional: this file must sort before every other spec.
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
import { test } from "./fixtures";
import { connectSeedClient } from "./helpers/seed-client";
import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab";
test.describe("Sessions screen empty state", () => {
test.beforeAll(async () => {
const client = await connectSeedClient();
try {
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
if (history.entries.length > 0) {
throw new Error(
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
`or the daemon has stale history from a previous run.`,
);
}
} finally {
await client.close().catch(() => undefined);
}
});
test("shows empty placeholder when there is no session history", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
await workspace.navigateTo();
await openSessions(page);
await expectSessionsEmptyState(page);
});
});
@@ -1,29 +0,0 @@
import { test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
const ACP_PROVIDER = {
id: "hermes",
name: "Hermes",
};
test.describe("ACP provider catalog", () => {
test("adds a catalog provider from settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
// Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
});
});
@@ -1,48 +0,0 @@
import { test } from "./fixtures";
import {
expectLoadedTimelineDoesNotScroll,
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
holdNextOlderTimelinePage,
makeLoadedTimelineFitViewport,
openAgentTimeline,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
} from "./helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await scrollTimelineUntilOlderHistoryIsReachable(page);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
try {
await makeLoadedTimelineFitViewport(page);
const olderPage = await holdNextOlderTimelinePage(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectLoadedTimelineDoesNotScroll(page);
await olderPage.expectLoading();
olderPage.release();
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
});
@@ -0,0 +1,70 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { submitMessage } from "../support/helpers/composer";
import { choosePermissionAction, expectPermissionActions } from "../support/helpers/permissions";
import {
cleanupRewindFlow,
launchAgent,
type AgentHandle,
type RewindFlowProvider,
} from "../support/helpers/rewind-flow";
const QUESTION = "Which option should I choose?";
const OPTIONS = ["Alpha", "Beta", "Gamma"];
async function askAgentToOfferChoices(handle: AgentHandle): Promise<void> {
await submitMessage(
handle.page,
`Use your built-in question tool now. Ask exactly "${QUESTION}" with exactly three choices named Alpha, Beta, and Gamma. Do not answer the question yourself. After I choose, reply exactly SELECTED_<CHOICE>, replacing <CHOICE> with my selection.`,
);
}
async function expectAgentToResumeWithSelection(handle: AgentHandle, selection: string) {
const finish = await handle.client.waitForFinish(handle.agentId, 240_000);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeFalsy();
await expect(
handle.page
.getByTestId("assistant-message")
.filter({ hasText: `SELECTED_${selection}` })
.last(),
).toBeVisible({ timeout: 30_000 });
}
async function capturePermissionChoices(page: Page, outputPath: string) {
await page.screenshot({ path: outputPath });
await expect(page.getByText(QUESTION, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Beta" })).toBeVisible();
}
test.use({ e2eForkProviders: ["kimi"] });
test.describe("real ACP permission choices", () => {
test.setTimeout(600_000);
test("Kimi renders its question choices and resumes with the selected option", async ({
page,
}, testInfo) => {
const provider = "kimi" satisfies RewindFlowProvider;
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-acp-question-kimi-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider, cwd, mode: "full-access" });
await askAgentToOfferChoices(handle);
await expectPermissionActions(page, OPTIONS);
const screenshotPath = testInfo.outputPath("kimi-permission-choices.png");
await capturePermissionChoices(page, screenshotPath);
await testInfo.attach("Kimi permission choices", {
path: screenshotPath,
contentType: "image/png",
});
await choosePermissionAction(page, "Beta");
await expectAgentToResumeWithSelection(handle, "BETA");
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -0,0 +1,43 @@
import { test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { getServerId } from "../support/helpers/server-id";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
const ACP_PROVIDER = {
id: "hermes",
name: "Hermes",
};
interface ProviderCatalogDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
}
test.describe("ACP provider catalog", () => {
test("adds a catalog provider from settings", async ({ page }) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "provider-catalog-e2e",
});
try {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
} finally {
await client.patchDaemonConfig({ removeProviders: [ACP_PROVIDER.id] }).catch(() => undefined);
await client.close().catch(() => undefined);
}
});
});
@@ -1,7 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
import { test, expect, type Page } from "../support/fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { test, expect } from "../support/fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -13,17 +13,21 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
openAddProjectHostSelection,
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
addConnectedHostAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
} from "../support/helpers/hosts";
import {
type IsolatedHostDaemon,
startIsolatedHostDaemon,
} from "../support/helpers/isolated-host-daemon";
import { expectOpenedProject } from "../support/helpers/project-picker-ui";
import { connectSeedClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
@@ -62,12 +66,14 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("a single connected host opens directly on method selection", async ({ page }) => {
test("method selection has no search field", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
await expect(addProjectFlowInput(page)).toHaveCount(0);
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
});
@@ -143,7 +149,7 @@ test.describe("Add Project command-center flow", () => {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await openAddProjectHostSelection(page);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
@@ -152,7 +158,7 @@ test.describe("Add Project command-center flow", () => {
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
});
test("Escape and Back restore page input and active selection before closing at the root", async ({
test("Escape and Back restore searchable page input before closing at the root", async ({
page,
}) => {
await gotoAppShell(page);
@@ -165,21 +171,20 @@ test.describe("Add Project command-center flow", () => {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await openAddProjectHostSelection(page);
await addProjectFlowInput(page).fill("o");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "method");
await addProjectFlowInput(page).fill("new");
await page.keyboard.press("Enter");
await chooseAddProjectMethod(page, "new-directory");
await expectAddProjectPage(page, "new-directory-parent");
await page.keyboard.press("Escape");
await expectAddProjectPage(page, "method");
await expect(addProjectFlowInput(page)).toHaveValue("new");
await page.keyboard.press("Enter");
await expect(addProjectFlowInput(page)).toHaveCount(0);
await chooseAddProjectMethod(page, "new-directory");
await expectAddProjectPage(page, "new-directory-parent");
await addProjectFlowBack(page).click();
@@ -213,7 +218,7 @@ test.describe("Add Project command-center flow", () => {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await openAddProjectHostSelection(page);
await addProjectFlowHost(page, SECONDARY_HOST_ID).click();
await expectAddProjectPage(page, "method");
@@ -1,7 +1,7 @@
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "./fixtures";
import { expect, test } from "../support/fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -10,12 +10,16 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { createTempGithubRepo, hasGithubAuth, type GhRepoFixture } from "./helpers/github-fixtures";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
createTempGithubRepo,
hasGithubAuth,
type GhRepoFixture,
} from "../support/helpers/github-fixtures";
import { expectOpenedProject } from "../support/helpers/project-picker-ui";
import { connectSeedClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
test.describe("Add Project GitHub flow", () => {
test.describe.configure({ timeout: 300_000 });
@@ -0,0 +1,893 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import {
attachImageFromMenu,
expectAttachmentPill,
expectComposerVisible,
submitMessage,
} from "../support/helpers/composer";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
interface FrameRect {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
}
interface ElementFrame {
mounted: boolean;
visible: boolean;
painted: boolean;
rect: FrameRect | null;
opacity: number | null;
backgroundColor: string | null;
}
interface ContentChildFrame extends ElementFrame {
key: string;
text: string;
}
interface TurnFrame {
at: number;
userRow: ElementFrame;
attachment: ElementFrame;
footerRow: ElementFrame;
spinner: ElementFrame;
agentTab: ElementFrame;
tabProgress: ElementFrame;
interruptControl: ElementFrame;
primaryActionCount: number;
composer: ElementFrame & { value: string | null };
contentChildren: ContentChildFrame[];
scroll: ElementFrame & {
scrollTop: number | null;
clientHeight: number | null;
scrollHeight: number | null;
};
}
interface ActivityCheckpoint {
row: boolean;
stop: boolean;
footer: boolean;
tabProgress: boolean;
elapsed: boolean;
}
interface ActivityOracleState {
phase: "waiting" | "observing" | "complete";
checkpoints: ActivityCheckpoint[];
observer: MutationObserver;
}
type RunningReleaseOrder = "turn-before-response" | "response-before-turn" | "snapshot-before-turn";
const FIRST_PROMPT_IMAGE = {
name: "first-prompt.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
};
declare global {
interface Window {
__consecutiveTurnFrames?: { active: boolean; frames: TurnFrame[] };
__activityContinuityOracle?: ActivityOracleState;
}
}
const test = baseTest.extend<{ streamingAgent: MockAgentWorkspace }>({
streamingAgent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "consecutive-ten-second-turns-",
title: "Consecutive ten-second turns",
model: "ten-second-stream",
initialPrompt: "emit 100 coalesced agent stream updates",
});
await agent.client.waitForFinish(agent.agentId, 15_000);
await provide(agent);
await agent.cleanup();
},
});
async function recordTurnFrames(page: Page, prompt: string): Promise<void> {
await page.evaluate((promptText) => {
const state = { active: true, frames: [] as TurnFrame[] };
const emptyElement = (): ElementFrame => ({
mounted: false,
visible: false,
painted: false,
rect: null,
opacity: null,
backgroundColor: null,
});
const rectOf = (element: Element): FrameRect => {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
};
};
const intersects = (first: FrameRect, second: FrameRect) =>
first.bottom > second.top &&
first.top < second.bottom &&
first.right > second.left &&
first.left < second.right;
const windowRect: FrameRect = {
top: 0,
right: innerWidth,
bottom: innerHeight,
left: 0,
width: innerWidth,
height: innerHeight,
};
const hasColor = (color: string) =>
color !== "" && color !== "none" && color !== "transparent" && color !== "rgba(0, 0, 0, 0)";
const isVisible = (element: Element, clip?: Element | null) => {
const rect = rectOf(element);
return (
element.isConnected &&
element.checkVisibility() &&
rect.width > 0 &&
rect.height > 0 &&
intersects(rect, windowRect) &&
(!clip || intersects(rect, rectOf(clip)))
);
};
const isPainted = (element: Element, clip?: Element | null) => {
if (!isVisible(element, clip)) return false;
return [element, ...Array.from(element.querySelectorAll("*"))].some((candidate) => {
if (!isVisible(candidate, clip)) return false;
const style = getComputedStyle(candidate);
if (Number(style.opacity) <= 0) return false;
if (hasColor(style.backgroundColor)) return true;
const hasPaintedBorder = ["Top", "Right", "Bottom", "Left"].some(
(side) =>
Number.parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 &&
hasColor(style.getPropertyValue(`border-${side.toLowerCase()}-color`)),
);
if (hasPaintedBorder) return true;
if (candidate.textContent?.trim() && hasColor(style.color)) return true;
if (candidate instanceof SVGElement) {
const fill = style.fill || candidate.getAttribute("fill") || "";
const stroke = style.stroke || candidate.getAttribute("stroke") || "";
return hasColor(fill) || hasColor(stroke);
}
return candidate instanceof HTMLImageElement || candidate instanceof HTMLCanvasElement;
});
};
const snapshot = (
element: Element | null | undefined,
clip?: Element | null,
painted?: boolean,
): ElementFrame => {
if (!element?.isConnected) return emptyElement();
const style = getComputedStyle(element);
return {
mounted: true,
visible: isVisible(element, clip),
painted: painted ?? isPainted(element, clip),
rect: rectOf(element),
opacity: Number(style.opacity),
backgroundColor: style.backgroundColor,
};
};
const unionRect = (elements: Element[]): FrameRect | null => {
if (elements.length === 0) return null;
const rects = elements.map(rectOf);
const top = Math.min(...rects.map((rect) => rect.top));
const right = Math.max(...rects.map((rect) => rect.right));
const bottom = Math.max(...rects.map((rect) => rect.bottom));
const left = Math.min(...rects.map((rect) => rect.left));
return { top, right, bottom, left, width: right - left, height: bottom - top };
};
const spinnerSnapshot = (
footer: Element | null | undefined,
clip: Element | null,
): ElementFrame => {
const dots = Array.from(footer?.querySelectorAll("*") ?? []).filter((candidate) =>
hasColor(getComputedStyle(candidate).backgroundColor),
);
if (dots.length === 0) return emptyElement();
const opacities = dots.map((dot) => Number(getComputedStyle(dot).opacity));
return {
mounted: true,
visible: dots.some((dot) => isVisible(dot, clip)),
painted: dots.some((dot, index) => opacities[index] > 0 && isVisible(dot, clip)),
rect: unionRect(dots),
opacity: Math.max(...opacities),
backgroundColor: getComputedStyle(dots[0]).backgroundColor,
};
};
const findImageAttachment = (row: Element | undefined) =>
row?.querySelector('[role="button"][aria-label="Open image attachment"]');
const findAgentTabState = () => {
const agentTab = Array.from(
document.querySelectorAll('[data-testid^="workspace-tab-agent_"]'),
).find((candidate) => isVisible(candidate));
return {
agentTab,
tabProgress: agentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]'),
};
};
const countPrimaryActions = (composerRoot: Element | null | undefined) =>
Array.from(composerRoot?.querySelectorAll('[role="button"][aria-label]') ?? []).filter(
(candidate) =>
/stop agent|canceling agent|interrupt agent|send message|send and interrupt|queue message/i.test(
candidate.getAttribute("aria-label") ?? "",
) && isVisible(candidate),
).length;
const sample = () => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const promptRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(promptText));
const attachment = findImageAttachment(promptRow);
const footer = Array.from(
viewport?.querySelectorAll('[data-testid="turn-working-indicator"]') ?? [],
)[0];
const spinner = spinnerSnapshot(footer, viewport ?? null);
const footerRow = footer?.parentElement;
const composer = Array.from(
document.querySelectorAll('[aria-label="Message agent..."]'),
).find((candidate) => isVisible(candidate));
const composerRoot = composer?.closest('[data-testid="message-input-root"]');
const interrupt = Array.from(
composerRoot?.querySelectorAll('[role="button"][aria-label]') ?? [],
).find((candidate) =>
/stop agent|canceling agent/i.test(candidate.getAttribute("aria-label") ?? ""),
);
const primaryActionCount = countPrimaryActions(composerRoot);
const { agentTab, tabProgress } = findAgentTabState();
const scrollFrame = snapshot(viewport);
const contentChildren = Array.from(viewport?.firstElementChild?.children ?? []).map(
(child, index): ContentChildFrame =>
Object.assign(snapshot(child, viewport), {
key:
(child.querySelector('[data-testid="turn-working-indicator"]')
? "turn-footer"
: (child.getAttribute("data-history-row-id") ??
child.getAttribute("data-testid"))) ?? `child-${index}`,
text: child.textContent?.trim().slice(0, 120) ?? "",
}),
);
state.frames.push({
at: performance.now(),
userRow: snapshot(promptRow, viewport),
attachment: snapshot(attachment, viewport),
footerRow: snapshot(footerRow, viewport, spinner.painted),
spinner,
agentTab: snapshot(agentTab),
tabProgress: snapshot(tabProgress),
interruptControl: snapshot(interrupt),
primaryActionCount,
composer: {
...snapshot(composerRoot ?? composer),
value:
composer instanceof HTMLInputElement || composer instanceof HTMLTextAreaElement
? composer.value
: null,
},
contentChildren,
scroll: {
...scrollFrame,
scrollTop: viewport instanceof HTMLElement ? viewport.scrollTop : null,
clientHeight: viewport instanceof HTMLElement ? viewport.clientHeight : null,
scrollHeight: viewport instanceof HTMLElement ? viewport.scrollHeight : null,
},
});
if (state.active) scheduleAfterNextPaint();
};
const scheduleAfterNextPaint = () => {
requestAnimationFrame(() => setTimeout(sample, 0));
};
window.__consecutiveTurnFrames = state;
scheduleAfterNextPaint();
}, prompt);
}
async function waitForRecordedFrames(
page: Page,
predicate: "user-visible" | "turn-running",
count: number,
): Promise<void> {
await expect
.poll(() =>
page.evaluate(
(input) => {
const frames = window.__consecutiveTurnFrames?.frames ?? [];
return frames.filter((frame) => {
if (input.predicate === "user-visible") return frame.userRow.visible;
return (
frame.interruptControl.painted &&
frame.footerRow.painted &&
frame.spinner.painted &&
frame.tabProgress.painted
);
}).length;
},
{ predicate },
),
)
.toBeGreaterThanOrEqual(count);
}
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
await expect
.poll(() => page.evaluate(() => window.__consecutiveTurnFrames?.frames.at(-1)?.at ?? 0))
.toBeGreaterThanOrEqual(until);
}
async function installActivityContinuityOracle(page: Page, prompt: string): Promise<void> {
await page.evaluate((promptText) => {
const isVisible = (element: Element | null) => Boolean(element?.checkVisibility());
const snapshot = (): ActivityCheckpoint => {
const visibleAgentTab = Array.from(
document.querySelectorAll('[data-testid^="workspace-tab-agent_"]'),
).find((candidate) => isVisible(candidate));
return {
row: Array.from(document.querySelectorAll('[data-testid="user-message"]')).some(
(candidate) => candidate.textContent?.includes(promptText),
),
stop: isVisible(document.querySelector('[role="button"][aria-label="Stop agent"]')),
footer: isVisible(document.querySelector('[data-testid="turn-working-indicator"]')),
tabProgress: isVisible(
visibleAgentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]') ??
null,
),
elapsed: isVisible(document.querySelector('[data-testid="turn-working-elapsed"]')),
};
};
const state = {
phase: "waiting" as ActivityOracleState["phase"],
checkpoints: [] as ActivityCheckpoint[],
observer: null as unknown as MutationObserver,
};
const observeCommit = () => {
const checkpoint = snapshot();
if (state.phase === "waiting" && checkpoint.row) {
state.phase = "observing";
}
if (state.phase !== "observing") return;
state.checkpoints.push(checkpoint);
if (checkpoint.elapsed) {
state.phase = "complete";
state.observer.disconnect();
}
};
state.observer = new MutationObserver(observeCommit);
window.__activityContinuityOracle = state;
state.observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
}, prompt);
}
async function waitForActivityOracleArmed(page: Page): Promise<void> {
await expect
.poll(() => page.evaluate(() => window.__activityContinuityOracle?.phase ?? "missing"))
.not.toBe("waiting");
}
async function readActivityContinuityOracle(page: Page): Promise<ActivityCheckpoint[]> {
return page.evaluate(() => {
const state = window.__activityContinuityOracle;
if (!state) throw new Error("Activity continuity oracle was never installed");
state.observer.disconnect();
return state.checkpoints;
});
}
function expectActivityContinuity(checkpoints: ActivityCheckpoint[]): void {
const incompleteActivityCheckpoints = checkpoints.filter(
(checkpoint) => !checkpoint.stop || !checkpoint.footer || !checkpoint.tabProgress,
);
expect(checkpoints[0]?.row, "activity oracle never armed on the optimistic row").toBe(true);
expect(
incompleteActivityCheckpoints,
`activity oracle observed row without full activity cluster:\n${JSON.stringify(incompleteActivityCheckpoints, null, 2)}\n\nall checkpoints:\n${JSON.stringify(checkpoints, null, 2)}`,
).toEqual([]);
expect(
checkpoints.slice(0, -1).filter((checkpoint) => checkpoint.elapsed),
`activity oracle observed elapsed before its authoritative disarm checkpoint:\n${JSON.stringify(checkpoints, null, 2)}`,
).toEqual([]);
expect(checkpoints.at(-1)?.elapsed, "activity oracle never observed authoritative elapsed").toBe(
true,
);
}
async function stopTurnFrameRecording(page: Page): Promise<TurnFrame[]> {
return page.evaluate(() => {
const state = window.__consecutiveTurnFrames;
if (!state) throw new Error("Turn frames were never recorded");
state.active = false;
return state.frames;
});
}
function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
const indexes = new Set<number>();
for (const center of Array.isArray(centers) ? centers : [centers]) {
for (let index = Math.max(0, center - 3); index <= center + 3; index += 1) {
if (index < frames.length) indexes.add(index);
}
}
return Array.from(indexes)
.sort((left, right) => left - right)
.map((index) => `frame ${index}: ${JSON.stringify(frames[index])}`)
.join("\n");
}
function expectRowContinuity(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const baseline = frames[first]?.userRow.rect?.top;
const disappeared = frames.findIndex(
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
);
const reappeared = frames.findIndex(
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
);
const shifted = frames.findIndex(
(frame, index) =>
index > first &&
hasPaintedLayout(frame.userRow) &&
baseline !== undefined &&
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
);
const attachmentMissing = frames.findIndex(
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
);
const violations = [
...(first < 0 ? ["submitted row never became visible"] : []),
...(disappeared >= 0
? [
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
]
: []),
...(shifted >= 0
? [
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
]
: []),
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
];
let failure = Math.max(0, first);
if (attachmentMissing >= 0) failure = attachmentMissing;
if (shifted >= 0) failure = shifted;
if (disappeared >= 0) failure = disappeared;
expect(
violations,
`transition violations:\n${formatFrames(frames, [failure, Math.max(failure, reappeared)])}`,
).toEqual([]);
}
interface FrameViolation {
frame: number;
reason: string;
}
function geometryChanged(
value: number | null | undefined,
expected: number | null | undefined,
): boolean {
if (value == null || expected == null) return true;
return Math.abs(value - expected) > 1;
}
function hasPaintedLayout(element: ElementFrame): boolean {
return [element.mounted, element.visible, element.painted].every(Boolean);
}
function violationsForChecks(
frame: number,
checks: Array<{ passes: boolean; reason: string }>,
): FrameViolation[] {
return checks.filter(({ passes }) => !passes).map(({ reason }) => ({ frame, reason }));
}
function collectElementViolations(
frame: TurnFrame,
baseline: TurnFrame | undefined,
index: number,
): FrameViolation[] {
return violationsForChecks(index, [
{
passes: hasPaintedLayout(frame.userRow),
reason: "submitted row left the painted layout",
},
{
passes: !geometryChanged(frame.userRow.rect?.top, baseline?.userRow.rect?.top),
reason: "submitted row moved vertically",
},
{
passes: [hasPaintedLayout(frame.footerRow), (frame.footerRow.rect?.height ?? 0) > 0].every(
Boolean,
),
reason: "footer row was absent from the painted layout",
},
{
passes: [
geometryChanged(frame.footerRow.rect?.top, baseline?.footerRow.rect?.top),
geometryChanged(frame.footerRow.rect?.height, baseline?.footerRow.rect?.height),
].every((hasChanged) => !hasChanged),
reason: "footer geometry moved",
},
{
passes: hasPaintedLayout(frame.spinner),
reason: "working spinner was not painted",
},
{
passes: hasPaintedLayout(frame.tabProgress),
reason: "selected tab running indicator was not painted",
},
{
passes: hasPaintedLayout(frame.interruptControl),
reason: "interrupt control was not painted",
},
{
passes: frame.primaryActionCount === 1,
reason: `expected one primary action, found ${frame.primaryActionCount}`,
},
{
passes: [hasPaintedLayout(frame.composer), frame.composer.value === ""].every(Boolean),
reason: "composer was not painted and empty",
},
{
passes: [
geometryChanged(frame.composer.rect?.top, baseline?.composer.rect?.top),
geometryChanged(frame.composer.rect?.height, baseline?.composer.rect?.height),
].every((hasChanged) => !hasChanged),
reason: "composer geometry moved",
},
]);
}
function collectScrollViolations(
frame: TurnFrame,
baseline: TurnFrame | undefined,
index: number,
): FrameViolation[] {
return violationsForChecks(index, [
{
passes: [
frame.scroll.mounted,
frame.scroll.visible,
!geometryChanged(frame.scroll.rect?.top, baseline?.scroll.rect?.top),
!geometryChanged(frame.scroll.rect?.height, baseline?.scroll.rect?.height),
!geometryChanged(frame.scroll.clientHeight, baseline?.scroll.clientHeight),
].every(Boolean),
reason: "scroll viewport geometry moved",
},
{
passes: [
geometryChanged(frame.scroll.scrollTop, baseline?.scroll.scrollTop),
geometryChanged(frame.scroll.scrollHeight, baseline?.scroll.scrollHeight),
].every((hasChanged) => !hasChanged),
reason: "scroll content metrics moved",
},
]);
}
function collectContentViolations(
frame: TurnFrame,
stableContentChildren: Map<string, ContentChildFrame>,
index: number,
): FrameViolation[] {
return Array.from(stableContentChildren).flatMap(([key, expected]) => {
const child = frame.contentChildren.find((candidate) => candidate.key === key);
return violationsForChecks(index, [
{
passes: [
Boolean(child?.mounted),
!geometryChanged(child?.rect?.top, expected.rect?.top),
!geometryChanged(child?.rect?.bottom, expected.rect?.bottom),
!geometryChanged(child?.rect?.height, expected.rect?.height),
].every(Boolean),
reason: `content row ${key} geometry moved`,
},
]);
});
}
function expectAtomicIdleToRunningTransition(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => frame.userRow.visible);
const running = frames.findIndex(
(frame, index) =>
index >= first &&
frame.interruptControl.painted &&
frame.footerRow.painted &&
frame.spinner.painted &&
frame.tabProgress.painted,
);
const transition = frames.slice(first);
const baseline = transition[0];
const violations: FrameViolation[] = [];
const stableContentChildren = new Map(
baseline?.contentChildren
.filter(({ key }) => key !== "turn-footer")
.map((child) => [child.key, child]),
);
if (first < 0) violations.push({ frame: 0, reason: "submitted row never became visible" });
if (running < 0) violations.push({ frame: Math.max(first, 0), reason: "turn never visibly ran" });
for (const [offset, frame] of transition.entries()) {
const index = first + offset;
violations.push(...collectElementViolations(frame, baseline, index));
violations.push(...collectScrollViolations(frame, baseline, index));
violations.push(...collectContentViolations(frame, stableContentChildren, index));
}
for (let offset = 1; offset < transition.length; offset += 1) {
const before = transition[offset - 1];
const after = transition[offset];
const footerEnteredLayout = [
(before.footerRow.rect?.height ?? 0) <= 0,
(after.footerRow.rect?.height ?? 0) > 0,
].every(Boolean);
if (footerEnteredLayout) {
violations.push({
frame: first + offset,
reason: "footer height changed from zero to non-zero",
});
}
}
const failure = violations[0]?.frame ?? Math.max(first, 0);
expect(
violations,
`transition violations:\n${violations.map(({ frame, reason }) => `frame ${frame}: ${reason}`).join("\n")}\n\nframe record:\n${formatFrames(frames, [failure, running])}`,
).toEqual([]);
}
function expectSubmittedMessageHeightStable(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => frame.userRow.visible);
const baseline = frames[first]?.userRow.rect?.height;
const changes = frames
.slice(first)
.flatMap((frame, offset) =>
geometryChanged(frame.userRow.rect?.height, baseline)
? [`frame ${first + offset}: submitted row height changed`]
: [],
);
expect(changes, `submitted message geometry changed:\n${changes.join("\n")}`).toEqual([]);
}
function expectAtomicFirstPromptTransition(frames: TurnFrame[]): void {
expectRowContinuity(frames);
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const transition = frames.slice(first);
const violations: FrameViolation[] = [];
for (const [offset, frame] of transition.entries()) {
const index = first + offset;
violations.push(
...violationsForChecks(index, [
{
passes: hasPaintedLayout(frame.footerRow),
reason: "footer was not painted with the first prompt",
},
{
passes: hasPaintedLayout(frame.spinner),
reason: "working spinner was not painted with the first prompt",
},
{
passes: hasPaintedLayout(frame.composer) && frame.composer.value === "",
reason: "composer was not painted and empty with the first prompt",
},
{
passes: !frame.agentTab.mounted || hasPaintedLayout(frame.tabProgress),
reason: "created agent tab appeared without running state",
},
{
passes: !frame.agentTab.mounted || hasPaintedLayout(frame.interruptControl),
reason: "created agent appeared without its interrupt control",
},
]),
);
}
const failure = violations[0]?.frame ?? Math.max(first, 0);
expect(
violations,
`transition violations:\n${violations.map(({ frame, reason }) => `frame ${frame}: ${reason}`).join("\n")}\n\nframe record:\n${formatFrames(frames, failure)}`,
).toEqual([]);
}
async function recordDelayedRunningTransition(
page: Page,
agent: MockAgentWorkspace,
releaseOrder: RunningReleaseOrder,
): Promise<{ frames: TurnFrame[]; checkpoints: ActivityCheckpoint[] }> {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectAgentIdle(page);
await expect(page.getByRole("button", { name: "Copy turn" })).toHaveCount(1);
const prompt = "Second prompt keeps streaming.";
gate.holdNextAgentStreamEvent("turn_started");
gate.holdNextAgentUpdate(agent.agentId, "running");
gate.holdNextServerMessage("send_agent_message_response");
gate.setAgentStreamItemSuppressed("assistant_message", true);
await recordTurnFrames(page, prompt);
await installActivityContinuityOracle(page, prompt);
await submitMessage(page, prompt);
await Promise.all([
gate.waitForHeldAgentStreamEvent("turn_started"),
gate.waitForHeldAgentUpdate(agent.agentId, "running"),
gate.waitForHeldServerMessage("send_agent_message_response"),
]);
let turnReleased = false;
let snapshotReleased = false;
let responseReleased = false;
try {
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue("");
await gate.waitForAgentStreamItem("user_message");
await waitForActivityOracleArmed(page);
const elapsedBeforeAuthoritative = await page.getByTestId("turn-working-elapsed").count();
if (releaseOrder === "turn-before-response") {
gate.releaseHeldAgentStreamEvent("turn_started");
turnReleased = true;
await expect(page.getByTestId("turn-working-elapsed")).toBeVisible();
gate.releaseHeldServerMessage("send_agent_message_response");
responseReleased = true;
gate.releaseHeldAgentUpdate(agent.agentId, "running");
snapshotReleased = true;
} else if (releaseOrder === "response-before-turn") {
gate.releaseHeldServerMessage("send_agent_message_response");
responseReleased = true;
await recordPaintsFor(page, 80);
gate.releaseHeldAgentStreamEvent("turn_started");
turnReleased = true;
await expect(page.getByTestId("turn-working-elapsed")).toBeVisible();
gate.releaseHeldAgentUpdate(agent.agentId, "running");
snapshotReleased = true;
} else {
gate.releaseHeldServerMessage("send_agent_message_response");
responseReleased = true;
await recordPaintsFor(page, 80);
gate.releaseHeldAgentUpdate(agent.agentId, "running");
snapshotReleased = true;
await expect(page.getByTestId("turn-working-elapsed")).toBeVisible();
gate.releaseHeldAgentStreamEvent("turn_started");
turnReleased = true;
}
await waitForRecordedFrames(page, "turn-running", 3);
const frames = await stopTurnFrameRecording(page);
const checkpoints = await readActivityContinuityOracle(page);
expectActivityContinuity(checkpoints);
expect(elapsedBeforeAuthoritative).toBe(0);
gate.setAgentStreamItemSuppressed("assistant_message", false);
return { frames, checkpoints };
} finally {
if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started");
if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running");
if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response");
gate.setAgentStreamItemSuppressed("assistant_message", false);
}
}
// The first prompt of a brand new agent is submitted before the agent exists, so the
// authoritative timeline arrives after the row is already on screen. That is the only
// path where hydration can move an already-visible message.
test("keeps the first prompt of a new agent in place through authoritative hydration", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "new-agent-first-prompt-" });
try {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify({
provider: "mock",
providerPreferences: {
mock: { mode: "load-test", model: "ten-second-stream" },
},
}),
);
});
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
const prompt = "Delay synthetic user message by 300ms.";
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await recordTurnFrames(page, prompt);
await submitMessage(page, prompt);
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
await expect(submittedRow).toBeVisible();
await gate.waitForServerMessage("fetch_agent_timeline_response");
await recordPaintsFor(page, 80);
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
} finally {
await workspace.cleanup();
}
});
test("keeps follow-up activity continuous when turn starts before response", async ({
page,
streamingAgent,
}) => {
const { frames } = await recordDelayedRunningTransition(
page,
streamingAgent,
"turn-before-response",
);
expectAtomicIdleToRunningTransition(frames);
});
test("keeps a submitted message height stable when its canonical echo arrives", async ({
page,
streamingAgent,
}) => {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, {
workspaceId: streamingAgent.workspaceId,
agentId: streamingAgent.agentId,
});
await expectAgentIdle(page);
const prompt = "Canonical echo must not resize this message.";
gate.holdNextAgentStreamItem("user_message");
await recordTurnFrames(page, prompt);
await submitMessage(page, prompt);
await gate.waitForHeldAgentStreamItem("user_message");
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(submittedRow).toBeVisible();
await recordPaintsFor(page, 80);
gate.releaseHeldAgentStreamItem("user_message");
await expect(submittedRow.getByTestId("rewind-menu-trigger")).toBeVisible();
await recordPaintsFor(page, 80);
expectSubmittedMessageHeightStable(await stopTurnFrameRecording(page));
});
test("keeps follow-up activity continuous when response arrives before running state", async ({
page,
streamingAgent,
}) => {
const { frames } = await recordDelayedRunningTransition(
page,
streamingAgent,
"response-before-turn",
);
expectAtomicIdleToRunningTransition(frames);
});
test("keeps follow-up activity continuous when snapshot opens before turn event", async ({
page,
streamingAgent,
}) => {
const { frames } = await recordDelayedRunningTransition(
page,
streamingAgent,
"snapshot-before-turn",
);
expectAtomicIdleToRunningTransition(frames);
});
@@ -0,0 +1,133 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import {
cancelAgent,
expectComposerDraft,
expectComposerVisible,
submitMessage,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function completeSubmittedTurn(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
prompt: string,
): Promise<Locator> {
await submitMessage(page, prompt);
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
}
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
await userMessage.getByText(prompt, { exact: true }).hover();
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
await page.getByRole("button", { name: "Rewind conversation", exact: true }).click();
}
async function expectTurnCompletesNormally(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
): Promise<void> {
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expectAgentIdle(page);
}
test.describe("Agent message rewind", () => {
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Restore this submitted prompt after rewind.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-roundtrip-",
title: "Message rewind roundtrip",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const userMessage = await completeSubmittedTurn(page, agent, prompt);
await rewindConversation(page, userMessage, prompt);
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await expectComposerDraft(page, prompt);
} finally {
await agent.cleanup();
}
});
test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Delay synthetic user message by 2000ms.";
const rewindError = "Cannot rewind before the provider acknowledges the submitted prompt";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-pre-echo-",
title: "Message rewind pre-echo",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessage(page, prompt);
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
await expectAgentReadyToInterrupt(page);
await rewindConversation(page, userMessage, prompt);
await expect(page.getByText(rewindError, { exact: true })).toBeVisible();
await expectAgentReadyToInterrupt(page);
await expectTurnCompletesNormally(page, agent);
} finally {
await agent.cleanup();
}
});
test("does not offer rewind for daemon-handled prompts", async ({ page }) => {
const commandPrompt = "/mock handled-command";
const providerPrompt = "Keep this provider-bound prompt rewindable.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-daemon-command-",
title: "Message rewind daemon command",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expectAgentIdle(page);
await submitMessage(page, commandPrompt);
const commandMessage = page.getByTestId("user-message").filter({ hasText: commandPrompt });
await expect(commandMessage).toBeVisible();
await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible();
await commandMessage.getByText(commandPrompt, { exact: true }).hover();
await expect(
commandMessage.getByRole("button", { name: "Rewind to this message", exact: true }),
).toHaveCount(0);
await submitMessage(page, providerPrompt);
const providerMessage = page.getByTestId("user-message").filter({ hasText: providerPrompt });
await expect(providerMessage).toBeVisible();
await expectAgentReadyToInterrupt(page);
await providerMessage.getByText(providerPrompt, { exact: true }).hover();
await expect(
providerMessage.getByRole("button", { name: "Rewind to this message", exact: true }),
).toBeVisible();
await cancelAgent(page);
await expectAgentIdle(page);
} finally {
await agent.cleanup();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
import { test } from "../support/fixtures";
import {
applyProfileFromPicker,
closeModelPicker,
drillIntoProvider,
expectComposerDoesNotName,
expectAgentProfilesEmptyPrompt,
expectProfileEditTooltip,
expectComposerMode,
expectComposerModel,
expectModelRowSelected,
expectNothingSelectedInPickerRoot,
expectProfileEditIsPencilOnly,
expectProfilePinnedAboveProviders,
expectProfileVisibleForProvider,
openModelPicker,
openAgentProfilesFromEmptyPrompt,
seedAgentProfiles,
} from "../support/helpers/agent-profiles";
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const PROFILE = {
id: "agent_profile_e2e_ui_work",
name: "UI work",
icon: "🎨",
provider: "mock",
model: "one-minute-stream",
modeId: "approval-test",
notes: "Use for UI work.",
};
const PROFILE_SUMMARY = "Mock Load Test · One minute stream · Approval test";
test.describe("Agent profiles in the model picker", () => {
test("an empty host still exposes agent profile settings from the picker", async ({ page }) => {
const seed = await seedAgentProfiles([]);
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-profiles-empty-",
title: "Agent profiles empty",
});
try {
await openAgentRoute(page, workspace);
await expectComposerVisible(page);
await openModelPicker(page);
await expectAgentProfilesEmptyPrompt(page);
await openAgentProfilesFromEmptyPrompt(page);
} finally {
await workspace.cleanup();
await seed.restore();
}
});
test("applying a pinned profile materializes it into the composer and is then forgotten", async ({
page,
}) => {
const seed = await seedAgentProfiles([PROFILE]);
// A live agent is one provider's process, so the profile has to name that
// same provider or the picker will not offer it at all.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-profiles-picker-",
title: "Agent profiles picker",
model: "ten-second-stream",
modeId: "load-test",
});
try {
await test.step("the agent starts on its seeded model and mode", async () => {
await openAgentRoute(page, workspace);
await expectComposerVisible(page);
await expectComposerModel(page, "Ten second stream");
await expectComposerMode(page, "Load test");
});
await test.step("the profile is pinned above the provider list", async () => {
await openModelPicker(page);
await expectProfilePinnedAboveProviders(page, {
name: PROFILE.name,
summary: PROFILE_SUMMARY,
});
await expectProfileEditIsPencilOnly(page);
await expectProfileEditTooltip(page);
});
await test.step("applying it writes its model and mode into the composer", async () => {
await applyProfileFromPicker(page, PROFILE.name);
await expectComposerModel(page, "One minute stream");
await expectComposerMode(page, "Approval test");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "one-minute-stream",
modeId: "approval-test",
});
});
await test.step("the composer names the model, never the profile", async () => {
await expectComposerDoesNotName(page, PROFILE.name);
});
await test.step("reopening the picker shows no profile marked as selected", async () => {
await openModelPicker(page);
await expectProfilePinnedAboveProviders(page, {
name: PROFILE.name,
summary: PROFILE_SUMMARY,
});
await expectNothingSelectedInPickerRoot(page);
});
await test.step("the model it materialized is the one marked selected", async () => {
await drillIntoProvider(page, "mock");
await expectProfileVisibleForProvider(page, {
name: PROFILE.name,
summary: PROFILE_SUMMARY,
});
await expectModelRowSelected(page, { provider: "mock", modelId: "one-minute-stream" });
await closeModelPicker(page);
});
} finally {
await workspace.cleanup();
await seed.restore();
}
});
});
@@ -0,0 +1,128 @@
import { test } from "../support/fixtures";
import {
createAgentProfile,
createAgentProfileFromEmptyState,
editAgentProfile,
expectAgentProfile,
expectAgentProfileForm,
expectAgentProfileOrder,
expectAgentProfileTagsGone,
expectHostAgentProfiles,
expectNoAgentProfiles,
moveAgentProfileUp,
openAgentProfileSettings,
removeAgentProfile,
seedAgentProfiles,
stageLegacyFavoritesForHostMigration,
} from "../support/helpers/agent-profiles";
const MOCK_PROVIDER_LABEL = "Mock Load Test";
test.describe("Agent profiles settings", () => {
test("legacy model favourites migrate into provider-and-model-only host profiles", async ({
page,
}) => {
const seed = await seedAgentProfiles([]);
const migratedProfile = {
id: "legacy_favorite:mock:one-minute-stream",
name: "One minute stream",
provider: "mock",
model: "one-minute-stream",
};
try {
await stageLegacyFavoritesForHostMigration(page, [
{ provider: "mock", modelId: "one-minute-stream" },
]);
await expectHostAgentProfiles([migratedProfile]);
await openAgentProfileSettings(page);
await expectAgentProfile(page, {
name: migratedProfile.name,
tags: [MOCK_PROVIDER_LABEL, "One minute stream"],
});
} finally {
await seed.restore();
}
});
test("host owner creates, edits, reorders and removes agent profiles", async ({ page }) => {
// The journey owns the list, so it starts from an empty one whatever the
// worker ran before, and hands it back untouched.
const seed = await seedAgentProfiles([]);
try {
await test.step("the section starts empty", async () => {
await openAgentProfileSettings(page);
await expectNoAgentProfiles(page);
});
await test.step("create a profile from the modal", async () => {
await createAgentProfileFromEmptyState(page, {
name: "UI work",
provider: MOCK_PROVIDER_LABEL,
model: "Ten second stream",
thinking: "High",
mode: "Approval test",
notes: "Use for UI work — components, layout and design tokens.",
});
await expectAgentProfile(page, {
name: "UI work",
tags: [MOCK_PROVIDER_LABEL, "Ten second stream", "Approval test", "High"],
notes: "Use for UI work — components, layout and design tokens.",
});
});
await test.step("editing to a model without thinking levels drops the level", async () => {
await editAgentProfile(page, "UI work", {
model: "One minute stream",
notes: "Now for quick checks.",
});
await expectAgentProfile(page, {
name: "UI work",
tags: [MOCK_PROVIDER_LABEL, "One minute stream", "Approval test"],
notes: "Now for quick checks.",
});
await expectAgentProfileTagsGone(page, {
name: "UI work",
tags: ["Ten second stream", "High"],
});
});
await test.step("the edit form reopens on what was stored", async () => {
await expectAgentProfileForm(page, "UI work", {
provider: MOCK_PROVIDER_LABEL,
model: "One minute stream",
mode: "Approval test",
notes: "Now for quick checks.",
});
});
await test.step("a second profile lands below the first", async () => {
await createAgentProfile(page, {
name: "Deep review",
provider: MOCK_PROVIDER_LABEL,
model: "Five minute stream",
});
await expectAgentProfileOrder(page, ["UI work", "Deep review"]);
});
await test.step("reorder puts the second profile on top", async () => {
await moveAgentProfileUp(page, "Deep review");
await expectAgentProfileOrder(page, ["Deep review", "UI work"]);
});
await test.step("removing a profile confirms by name", async () => {
await removeAgentProfile(page, "UI work");
await expectAgentProfileOrder(page, ["Deep review"]);
});
await test.step("removing the last profile returns the empty state", async () => {
await removeAgentProfile(page, "Deep review");
await expectNoAgentProfiles(page);
});
} finally {
await seed.restore();
}
});
});
@@ -0,0 +1,236 @@
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import {
readScrollMetrics,
scrollChatAwayFromBottom,
} from "../support/helpers/agent-bottom-anchor";
import { getServerId } from "../support/helpers/server-id";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "../support/helpers/mock-agent";
import {
continueToNextQuestion,
fillQuestionAnswer,
submitQuestionAnswers,
waitForQuestionPrompt,
} from "../support/helpers/questions";
import { openMobileAgentSidebar } from "../support/helpers/sidebar";
import {
expectTimelinePromptVisible,
openAgentTimeline,
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
async function expectChatAtBottom(page: Page): Promise<void> {
await expect
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom, {
timeout: 10_000,
})
.toBeLessThanOrEqual(72);
}
async function openCompactWorkspace(
page: Page,
workspace: Pick<MockAgentWorkspace, "workspaceId">,
): Promise<void> {
await openMobileAgentSidebar(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
});
}
function armDelayedScrollForHiddenChat(page: Page): Promise<void> {
return page
.locator('[data-testid="agent-chat-scroll"]:visible')
.first()
.evaluate((root) => {
const scroll = root as HTMLElement;
return new Promise<void>((resolve) => {
const observer = new ResizeObserver(() => {
if (scroll.getClientRects().length > 0) return;
observer.disconnect();
scroll.dispatchEvent(new Event("scroll"));
resolve();
});
observer.observe(scroll);
});
});
}
test("pointer jitter cannot turn geometry changes into scroll intent", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const backgroundPrompt = "Continue after a pointer is released outside the chat";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const bounds = await scroll.boundingBox();
expect(bounds, "Expected visible chat scroll bounds").not.toBeNull();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2);
await page.mouse.down();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 - 3);
await page.setViewportSize({ width: 390, height: 1124 });
await page.setViewportSize({ width: 390, height: 844 });
await page.mouse.move(410, bounds!.y + bounds!.height / 2 - 3);
await page.mouse.up();
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await expectTimelinePromptVisible(page, backgroundPrompt);
await expectChatAtBottom(page);
} finally {
await longChat.cleanup();
}
});
test("a retained chat ignores delayed scroll delivery while hidden", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const otherWorkspace = await seedMockAgentWorkspace({
repoPrefix: "scroll-return-hidden-other-",
title: "Other workspace",
initialPrompt: "Open the other workspace",
});
const backgroundPrompt = "emit 500 coalesced agent stream updates";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
const delayedScrollDelivered = armDelayedScrollForHiddenChat(page);
await openCompactWorkspace(page, otherWorkspace);
await delayedScrollDelivered;
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await openCompactWorkspace(page, longChat);
await expectTimelinePromptVisible(page, backgroundPrompt);
await expectChatAtBottom(page);
} finally {
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
}
});
test("a retained streaming chat returns to the bottom and continues following", async ({
page,
}) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const otherWorkspace = await seedMockAgentWorkspace({
repoPrefix: "scroll-return-streaming-other-",
title: "Other workspace",
initialPrompt: "Open the other workspace",
});
const streamingPrompt = "Continue streaming while this workspace is hidden";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
await longChat.client.sendAgentMessage(longChat.agentId, streamingPrompt);
await longChat.client.waitForAgentUpsert(
longChat.agentId,
(snapshot) => snapshot.status === "running",
15_000,
);
await openCompactWorkspace(page, otherWorkspace);
await openCompactWorkspace(page, longChat);
await expectTimelinePromptVisible(page, streamingPrompt);
await expectChatAtBottom(page);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await expectChatAtBottom(page);
} finally {
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
}
});
test("a retained chat preserves an intentional reading position", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const otherWorkspace = await seedMockAgentWorkspace({
repoPrefix: "scroll-return-reading-position-other-",
title: "Other workspace",
initialPrompt: "Open the other workspace",
});
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
const readingPosition = await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
await openCompactWorkspace(page, otherWorkspace);
await openCompactWorkspace(page, longChat);
await expect
.poll(async () => Math.abs((await readScrollMetrics(page)).offsetY - readingPosition.offsetY))
.toBeLessThanOrEqual(24);
} finally {
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
}
});
test("editing keys in an embedded question keep output following", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const questionPrompt = "Emit synthetic questions: two free-write questions.";
const firstQuestion = "What is the GitHub private repo URL to push to?";
const secondQuestion = "What should the first commit message be?";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
await longChat.client.sendAgentMessage(longChat.agentId, questionPrompt);
await waitForQuestionPrompt(page, 30_000);
await fillQuestionAnswer(page, {
question: firstQuestion,
answer: "git@github.com:user/private-repo.git",
});
const firstInput = page
.getByTestId("question-form-card")
.first()
.getByRole("textbox", { name: firstQuestion });
const inputBounds = await firstInput.boundingBox();
expect(inputBounds, "Expected embedded question input bounds").not.toBeNull();
await page.mouse.move(
inputBounds!.x + inputBounds!.width / 2,
inputBounds!.y + inputBounds!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
inputBounds!.x + inputBounds!.width / 2 + 20,
inputBounds!.y + inputBounds!.height / 2 - 3,
);
await page.mouse.up();
await firstInput.press("Home");
await firstInput.press("ArrowUp");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, {
question: secondQuestion,
answer: "Initialize private repo",
});
await submitQuestionAnswers(page);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await expectChatAtBottom(page);
} finally {
await longChat.cleanup();
}
});
@@ -1,11 +1,12 @@
import { test, expect } from "./fixtures";
import { test, expect } from "../support/fixtures";
import {
awaitAssistantMessage,
expectAgentIdle,
expectInlineWorkingIndicator,
expectRunningAgentChrome,
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
} from "../support/helpers/agent-stream";
import {
expectScrollStaysFixed,
clickToolCallBesideScrollToBottomButton,
@@ -13,16 +14,40 @@ import {
scrollAgentChatToBottom,
scrollChatAwayFromBottom,
waitForScrollableChat,
} from "./helpers/agent-bottom-anchor";
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
import { selectModel } from "./helpers/app";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
} from "../support/helpers/agent-bottom-anchor";
import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate";
import { selectModel } from "../support/helpers/app";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible, startRunningMockAgent } from "../support/helpers/composer";
import {
openAgentRoute,
seedMockAgentWorkspace,
seedRunningMockAgentWorkspace,
} from "../support/helpers/mock-agent";
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
test.describe("Agent stream UI", () => {
test("keeps running agent chrome after page refresh", async ({ page }) => {
const title = "Running agent refresh";
const agent = await seedRunningMockAgentWorkspace({
repoPrefix: "stream-running-refresh-",
title,
model: "five-minute-stream",
initialPrompt: "Stay running while the page refreshes.",
});
try {
await openAgentRoute(page, agent);
await expectRunningAgentChrome(page, title);
await page.reload();
await expectRunningAgentChrome(page, title);
} finally {
await agent.cleanup();
}
});
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
@@ -0,0 +1,210 @@
import { expect, test as base } from "../support/fixtures";
import {
appendSettledTimelineTurns,
createNearTenMegabyteAssistantPng,
createSettledMockAgent,
createSmallAssistantPng,
emitSettledAssistantImage,
expectAssistantImageNotMounted,
expectAssistantImageRendered,
openAssistantImageTimeline,
openExistingImageAgentTabs,
remountAndRecoverAssistantImageFromHistory,
sendFollowUpAndExpectVisibleResponse,
switchAwayAndBackWithoutImageInstability,
userPagesUntilAssistantImageRenders,
} from "../support/helpers/assistant-images";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
const workspace = await seedWorkspace({ repoPrefix: "agent-tab-image-stability-" });
try {
await provide(workspace);
} finally {
await workspace.cleanup();
}
},
});
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Real file image",
fileName: "assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Image timeline");
const otherAgent = await createSettledMockAgent(workspace, "Other timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openExistingImageAgentTabs(page, { imageAgent, otherAgent });
await expectAssistantImageRendered(page, image);
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
});
test("reloading a timeline anchors near-tail assistant image growth", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Reload geometry image",
fileName: "reload-geometry-image.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Reload image geometry");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
await page.addInitScript(
({ accessibleName }) => {
interface ImageGeometryEvent {
atMs: number;
complete: boolean;
height: number;
naturalHeight: number;
naturalWidth: number;
scrollTop: number;
width: number;
}
const state = {
accessibleName,
events: [] as ImageGeometryEvent[],
mutationObserver: null as MutationObserver | null,
resizeObserver: null as ResizeObserver | null,
target: null as Element | null,
};
const record = () => {
const target = state.target;
if (!target) return;
const imageElement =
target instanceof HTMLImageElement ? target : target.querySelector("img");
const scroll = target.closest('[data-testid="agent-chat-scroll"]');
const rect = target.getBoundingClientRect();
const event: ImageGeometryEvent = {
atMs: performance.now(),
complete: imageElement?.complete ?? false,
height: rect.height,
naturalHeight: imageElement?.naturalHeight ?? 0,
naturalWidth: imageElement?.naturalWidth ?? 0,
scrollTop: scroll instanceof HTMLElement ? scroll.scrollTop : -1,
width: rect.width,
};
const previous = state.events.at(-1);
if (
!previous ||
previous.complete !== event.complete ||
previous.height !== event.height ||
previous.naturalHeight !== event.naturalHeight ||
previous.naturalWidth !== event.naturalWidth ||
previous.scrollTop !== event.scrollTop ||
previous.width !== event.width
) {
state.events.push(event);
}
};
const inspect = () => {
const target = Array.from(document.querySelectorAll('[role="img"]')).find(
(element) => element.getAttribute("aria-label") === accessibleName,
);
if (!target || target === state.target) {
record();
return;
}
state.target = target;
state.resizeObserver?.disconnect();
state.resizeObserver = new ResizeObserver(record);
state.resizeObserver.observe(target);
record();
};
const start = () => {
state.mutationObserver = new MutationObserver(inspect);
state.mutationObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["src", "style"],
childList: true,
subtree: true,
});
inspect();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
(
window as unknown as {
__paseoReloadImageGeometry?: typeof state;
}
).__paseoReloadImageGeometry = state;
},
{ accessibleName: image.alt },
);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.reload();
await expectAssistantImageRendered(page, image);
await page.waitForTimeout(500);
const events = await page.evaluate(() => {
const state = (
window as unknown as {
__paseoReloadImageGeometry?: { events: Array<unknown> };
}
).__paseoReloadImageGeometry;
if (!state) throw new Error("Reload image geometry observer was not installed");
return state.events;
});
const geometryEvents = events as Array<{ height: number; scrollTop: number }>;
const initialGeometry = geometryEvents.find((event) => event.height > 0);
if (!initialGeometry)
throw new Error(`Image geometry was never measurable: ${JSON.stringify(events)}`);
const expandedGeometry = geometryEvents.find((event) => event.height > initialGeometry.height);
if (!expandedGeometry)
throw new Error(`Image geometry never expanded: ${JSON.stringify(events)}`);
expect(expandedGeometry.scrollTop, JSON.stringify(events)).toBeGreaterThan(
initialGeometry.scrollTop,
);
});
test("a real assistant PNG remains reachable through pagination and remount", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Paginated real file image",
fileName: "paginated-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Paginated image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await appendSettledTimelineTurns(workspace.client, imageAgent, 40);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageNotMounted(page, image);
await userPagesUntilAssistantImageRenders(page, image);
await remountAndRecoverAssistantImageFromHistory(page, image);
});
test("a near-10 MiB real assistant PNG renders and the app remains responsive", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(180_000);
const image = await createNearTenMegabyteAssistantPng(workspace, {
alt: "Large real file image",
fileName: "large-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Large image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await sendFollowUpAndExpectVisibleResponse(page, {
prompt: "confirm responsiveness: emit 1 coalesced agent stream updates",
response: "stress-update-0",
});
});
@@ -0,0 +1,86 @@
import { randomUUID } from "node:crypto";
import { metroTest as test } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectTimelinePromptVisible,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
} from "../support/helpers/timeline-pagination";
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const workspace = await seedWorkspace({
repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
});
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Published daemon pagination regression",
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
try {
for (let index = 0; index < 40; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
);
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
} finally {
await agent.cleanup();
await daemon.close();
}
});
@@ -0,0 +1,259 @@
import { expect, test } from "../support/fixtures";
import {
expectSameOlderHistoryLoadingOperation,
expectStableHistoryStartGutter,
expectTimelineAtHistoryStart,
expectTimelinePromptCentered,
expectTimelinePromptNotMounted,
expectTimelinePromptPositionPreserved,
expectTimelinePromptVisible,
holdBootstrapTimelinePage,
holdDaemonHydration,
holdOlderHistoryPages,
makeLoadedTimelineFitViewport,
openAgentTimeline,
rememberOlderHistoryLoadingOperation,
rememberTimelineViewport,
rememberTimelinePromptPosition,
reloadAgentTimelineFromPersistedReplica,
scrollTimelineUntilOlderHistoryIsReachable,
scrollTimelineToOldestLoadedEdge,
scrollTimelineToNewestLoadedEdge,
seedLongMockAgentTimeline,
scrollThroughOlderHistoryPages,
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectStableHistoryStartGutter(page);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await expectStableHistoryStartGutter(page);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectStableHistoryStartGutter(page);
} finally {
await agent.cleanup();
}
});
test("loads one page each time the user returns to history start", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptCentered(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(2);
} finally {
await agent.cleanup();
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userNavigatesTimelineToHistoryStartWithKeyboard(page);
await history.expectRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("does not repeat an assistant block that spans older history pages", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedRealisticOlderTimeline();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectNoRepeatedEntries();
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
} finally {
await agent.cleanup();
}
});
test("keeps visible history anchored when live output grows during a prepend", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
await agent.client.sendAgentMessage(
agent.agentId,
"timeline live during held older page: emit 20 coalesced agent stream updates",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
} finally {
await agent.cleanup();
}
});
test("finishes loading an older page while live output continues", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await agent.client.sendAgentMessage(agent.agentId, "keep streaming while history settles");
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "running",
);
history.releasePage(1);
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
const running = await agent.client.fetchAgents({ scope: "active" });
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
"running",
);
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("continues one loading operation while older pages still leave history start exposed", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await makeLoadedTimelineFitViewport(page);
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await scrollTimelineToOldestLoadedEdge(page);
await history.expectRequestedPages(1);
const loading = await rememberOlderHistoryLoadingOperation(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectTimelineAtHistoryStart(page);
await expectSameOlderHistoryLoadingOperation(page, loading);
} finally {
await agent.cleanup();
}
});
test("keeps complete loaded history reachable after reload", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
const hydration = await holdDaemonHydration(page);
await reloadAgentTimelineFromPersistedReplica(page, agent);
hydration.release();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
test("preserves a live row received before replica hydration", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
const hydration = await holdBootstrapTimelinePage(page, agent);
await reloadAgentTimelineFromPersistedReplica(page, agent);
await hydration.waitForDelayedResponse();
const livePrompt = await sendLiveTurnBeforeHydration(agent);
await expectTimelinePromptVisible(page, livePrompt);
hydration.release();
await hydration.waitForDelayedCatchUp();
await expectTimelinePromptVisible(page, livePrompt);
hydration.releaseCatchUp();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, livePrompt);
} finally {
await agent.cleanup();
}
});
});
@@ -0,0 +1,65 @@
import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectTimelinePresentationUnchanged,
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
openAgentTimeline,
rememberTimelinePresentation,
scrollTimelinePromptIntoView,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectOneTailCheckWithoutCatchUpPages,
rememberTimelineRequestCounts,
restoreViewedTimelineWithHeldTail,
} from "../support/helpers/timeline-resume";
test.describe("Agent timeline resume", () => {
test("a long disconnected run lands as one latest-tail update", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
await restoreViewedTimelineWithHeldTail(page, gate);
await expectTimelinePromptVisible(page, background.newestPrompt);
await expectTimelinePromptNotMounted(page, background.firstPrompt);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
} finally {
gate.restore();
await agent.cleanup();
}
});
test("an unchanged resume leaves a scrolled-up timeline untouched", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await scrollTimelinePromptIntoView(page, agent.initialTailAnchorPrompt);
const presentation = await rememberTimelinePresentation(page, agent.initialTailAnchorPrompt);
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
await restoreViewedTimelineWithHeldTail(page, gate);
await expectTimelinePresentationUnchanged(page, presentation);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
}
});
});
@@ -0,0 +1,66 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsSection } from "../support/helpers/settings";
test("shows Pure black in the appearance picker", async ({ page }, testInfo) => {
await page.goto("/settings");
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
await themeTrigger.click();
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("appearance-theme-picker.png"),
fullPage: true,
});
});
test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "pure-black-selected-workspace-",
title: "Selected workspace",
});
try {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
});
await gotoAppShell(page);
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)");
await page.screenshot({
path: testInfo.outputPath("pure-black-selected-workspace.png"),
fullPage: true,
});
} finally {
await workspace.cleanup();
}
});
test("applies the interface font size to settings text", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
});
await page.goto("/settings");
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
const sectionTitle = page.getByText("Theme", { exact: true }).first();
await expect(sectionTitle).toHaveCSS("font-size", "18px");
const fontSizeInput = page.getByLabel("Interface font size");
await expect(fontSizeInput).toHaveValue("24");
await fontSizeInput.fill("12");
await fontSizeInput.press("Tab");
await expect(fontSizeInput).toHaveValue("12");
await expect(sectionTitle).toHaveCSS("font-size", "9px");
});
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { test } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import {
archiveAgentFromDaemon,
@@ -20,7 +20,7 @@ import {
primeAdditionalPage,
resetSeededPageState,
reloadWorkspace,
} from "./helpers/archive-tab";
} from "../support/helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
@@ -1,18 +1,18 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { openSessions } from "./helpers/archive-tab";
import { test, expect } from "../support/fixtures";
import { openSessions } from "../support/helpers/archive-tab";
import {
assertChatTranscript,
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
} from "./helpers/rewind-flow";
import type { SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
} from "../support/helpers/rewind-flow";
import type { SeedDaemonClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
interface TimelineClient extends SeedDaemonClient {
fetchAgentTimeline(
@@ -1,17 +1,25 @@
import { expect, test as base, type Page } from "./fixtures";
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "./helpers/agent-stream";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { expect, test as base } from "../support/fixtures";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import {
expectChatHistoryAttachment,
expectInFlightForkAvailable,
expectLiveAssistantText,
forkInFlightTurnToNewTab,
forkMostRecentAssistantTurnToNewTab,
forkMostRecentAssistantTurnToNewWorkspace,
observeForkAttachment,
} from "../support/helpers/assistant-fork";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentOptions,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { getServerId } from "./helpers/server-id";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { submitNewWorkspaceEmpty } from "./helpers/new-workspace";
} from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { submitNewWorkspaceEmpty } from "../support/helpers/new-workspace";
const test = base.extend<{
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
@@ -27,30 +35,6 @@ const test = base.extend<{
},
});
async function openAssistantForkMenu(page: Page): Promise<void> {
await expect
.poll(
async () => {
await scrollAgentChatToBottom(page);
return page.getByTestId("assistant-fork-menu-trigger").count();
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
timeout: 10_000,
});
}
async function expectChatHistoryPill(page: Page): Promise<void> {
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
await expect(pill).toBeVisible({ timeout: 30_000 });
await expect(pill).toContainText("Chat history");
}
test.describe("Assistant fork menu", () => {
test.describe.configure({ timeout: 180_000 });
@@ -71,9 +55,38 @@ test.describe("Assistant fork menu", () => {
timeout: 30_000,
});
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
await expectChatHistoryPill(page);
await forkMostRecentAssistantTurnToNewTab(page);
await expectChatHistoryAttachment(page);
});
test("forks a streaming assistant turn without interrupting it", async ({
page,
seedForkWorkspace,
}) => {
const visibleBeforeFork = "where the auto-scroll logic actually lives";
const visibleAfterFork = "the first useful step is to read the relevant files";
const sourceAgentTitle = "Assistant fork in flight";
const forkAttachment = observeForkAttachment(page);
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-in-flight-",
title: sourceAgentTitle,
model: "thirty-minute-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await submitMessage(page, "Walk me through the scroll anchor behavior.");
await expectInFlightForkAvailable(page);
await expectLiveAssistantText(page, visibleBeforeFork);
await forkInFlightTurnToNewTab(page);
await expectChatHistoryAttachment(page);
expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork);
await page.getByRole("button", { name: sourceAgentTitle }).click();
await expectLiveAssistantText(page, visibleAfterFork);
});
test("focuses a forked assistant turn in a new workspace draft tab", async ({
@@ -99,8 +112,7 @@ test.describe("Assistant fork menu", () => {
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
await expect(agentTab).toHaveAttribute("aria-selected", "true");
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
await forkMostRecentAssistantTurnToNewTab(page);
const selectedTab = page
.getByTestId("workspace-tabs-row")
@@ -110,7 +122,7 @@ test.describe("Assistant fork menu", () => {
timeout: 30_000,
});
await expect(agentTab).toHaveAttribute("aria-selected", "false");
await expectChatHistoryPill(page);
await expectChatHistoryAttachment(page);
});
test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({
@@ -129,9 +141,8 @@ test.describe("Assistant fork menu", () => {
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
await expectChatHistoryPill(page);
await forkMostRecentAssistantTurnToNewTab(page);
await expectChatHistoryAttachment(page);
await submitMessage(page, "");
@@ -171,17 +182,16 @@ test.describe("Assistant fork menu", () => {
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-workspace").click();
await forkMostRecentAssistantTurnToNewWorkspace(page);
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
await expectChatHistoryPill(page);
await expectChatHistoryAttachment(page);
await page.getByTestId("host-picker-trigger").click();
await page
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
.click();
await expectChatHistoryPill(page);
await expectChatHistoryAttachment(page);
});
test("keeps the fork attachment after the new agent receives its user message", async ({
@@ -200,9 +210,8 @@ test.describe("Assistant fork menu", () => {
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-workspace").click();
await expectChatHistoryPill(page);
await forkMostRecentAssistantTurnToNewWorkspace(page);
await expectChatHistoryAttachment(page);
await submitNewWorkspaceEmpty(page);
@@ -0,0 +1,587 @@
import type { BrowserContext } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const CROSS_STRUCTURE_MARKDOWN = [
"# P1 — High-value UI behavior",
"",
"5. Claude model and thinking preferences",
"",
" - Switch Claude models and confirm each model retains its own thinking choice.",
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
" - Confirm Opus 5 exposes the Fast toggle.",
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
"",
"6. Chat, composer, and timeline stability",
].join("\n");
const ASSISTANT_MARKDOWN = [
CROSS_STRUCTURE_MARKDOWN,
"",
"Direct matches:",
"",
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
"",
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
"",
"5. Fifth item",
"6. Sixth item",
"7. Seventh item",
"8. Eighth item",
"",
"Nested list:",
"",
"3. Outer three",
"",
" 7. Inner seven",
" 8. Inner eight",
" 9. Inner nine",
"",
"A hard break ",
"stays hard.",
"",
"www.example.com stays plain Markdown text.",
"",
'[docs](https://docs.example.com "Reference") and <https://autolink.example.com>.',
"",
"[workspace file](file:///tmp/paseo%20notes.md#L4)",
"",
"`https://example.com/generated` stays code, not a generated link.",
"",
"```typescript",
'const answer = "yes";',
" return answer;",
"```",
"",
"After code.",
"",
"```bash",
"echo trailing",
"",
"```",
"",
"````text",
"before",
"```",
"after",
"````",
"",
"| Left | Right | Center |",
"| :-- | --: | :-: |",
"| Current \\| active | ~~obsolete~~ | ready |",
].join("\n");
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
`${CROSS_STRUCTURE_MARKDOWN}\n\n`,
"",
)
.replace(
"<https://autolink.example.com>",
"[https://autolink.example.com](https://autolink.example.com)",
)
.replace(
"3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
"3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
)
// Rendering drops the blank line before a closing fence, so copying cannot bring it
// back. The fence is here to cover a body that ends in more than one newline.
.replace("```bash\necho trailing\n\n```", "```bash\necho trailing\n```");
interface ClipboardContent {
html: string;
plainText: string;
}
async function allowRichClipboard(context: BrowserContext): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
}
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
await selectAssistantTextRange(page, "Direct matches:", "ready");
}
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.locator(selector).evaluate((element) => {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(element);
selection?.removeAllRanges();
selection?.addRange(range);
});
}
async function selectAssistantText(page: Page, text: string): Promise<void> {
await selectAssistantTextRange(page, text, text);
}
async function selectAssistantListItemFromMarker(
page: Page,
listTag: "ol" | "ul",
itemText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const item = assistantMessage
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
.filter({ hasText: itemText })
.locator(':scope > [data-paseo-markdown-tag="li"]')
.filter({ hasText: itemText });
await item.evaluate((element, selectedEndText) => {
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
const markerText = marker?.firstChild;
if (!(markerText instanceof Text)) {
throw new Error("Expected rendered list marker text");
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let endNode: Text | null = null;
let endOffset = -1;
while (walker.nextNode()) {
const node = walker.currentNode;
const offset = node.textContent?.indexOf(selectedEndText) ?? -1;
if (node instanceof Text && offset >= 0) {
endNode = node;
endOffset = offset + selectedEndText.length;
break;
}
}
if (!endNode) {
throw new Error(`Could not find list item selection end: ${selectedEndText}`);
}
const range = document.createRange();
range.setStart(markerText, 0);
range.setEnd(endNode, endOffset);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}, endText);
}
async function doubleClickAssistantMarkdownText(
page: Page,
tag: "code" | "strong",
text: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text })
.dblclick({ position: { x: 5, y: 5 } });
}
async function selectAssistantTextRange(
page: Page,
startText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let startNode: Node | null = null;
let startOffset = -1;
let endNode: Node | null = null;
let endOffset = -1;
let textNode = walker.nextNode();
while (textNode) {
if (!startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
if (offset >= 0) {
startNode = textNode;
startOffset = offset;
}
}
if (startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
if (offset >= 0) {
endNode = textNode;
endOffset = offset + selectedRange.endText.length;
break;
}
}
textNode = walker.nextNode();
}
if (!startNode || !endNode) {
throw new Error(
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
);
}
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
},
{ startText, endText },
);
}
/**
* Select from `startText` into the middle of the next rendered code line.
*
* Highlighted code splits a line across one text node per syntax token and puts the
* newline in a node of its own, so this is a partial selection that crosses a line
* break — the shape whose whitespace Turndown would collapse.
*/
async function selectAssistantAcrossCodeLines(
page: Page,
startText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let startNode: Node | null = null;
let startOffset = -1;
let textNode = walker.nextNode();
while (textNode) {
if (!startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
if (offset >= 0) {
startNode = textNode;
startOffset = offset;
}
textNode = walker.nextNode();
continue;
}
const endOffset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
if (endOffset >= 0) {
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(textNode, endOffset + selectedRange.endText.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
return;
}
textNode = walker.nextNode();
}
throw new Error(
`Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`,
);
},
{ startText, endText },
);
}
async function copySelection(page: Page): Promise<void> {
await page.keyboard.press("ControlOrMeta+c");
}
/** The Copy button writes text/plain only, so the rich reader would throw on it. */
async function readPlainClipboard(page: Page): Promise<string> {
return page.evaluate(() => navigator.clipboard.readText());
}
async function readRichClipboard(page: Page): Promise<ClipboardContent> {
return page.evaluate(async () => {
const items = await navigator.clipboard.read();
const item = items[0];
if (!item) {
throw new Error("Expected clipboard content");
}
return {
html: await (await item.getType("text/html")).text(),
plainText: await (await item.getType("text/plain")).text(),
};
});
}
test("copying an assistant selection preserves Markdown structure and links", async ({
context,
page,
}) => {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "serif" }));
});
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-copy-",
title: "Assistant selection copy",
initialPrompt: "Render the clipboard fixture.",
featureValues: { mockAssistantResponse: ASSISTANT_MARKDOWN },
});
try {
await allowRichClipboard(context);
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await openAgentRoute(page, agent);
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
for (const [tag, text] of [
["strong", "strong prose"],
["em", "emphasized prose"],
["s", "struck prose"],
]) {
const formattedProse = assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text });
await expect(formattedProse).toHaveCSS("font-family", "serif");
await expect(formattedProse).not.toHaveAttribute("data-pmono");
}
const inlineCode = assistantMessage
.locator('[data-paseo-markdown-tag="code"]')
.filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveAttribute("data-pmono", "");
await selectStructuredClipboardFixture(page);
await copySelection(page);
const clipboard = await readRichClipboard(page);
expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN);
expect(clipboard.html).not.toContain("<ul>");
expect(clipboard.html).not.toContain("<li>");
expect(clipboard.html).toContain("<div>- ");
expect(clipboard.html).toContain(
'<strong><a href="https://example.com/issues/1">First issue</a></strong>',
);
expect(clipboard.html).toContain("<code>apply_patch</code>");
expect(clipboard.html).toContain("<div>5. Fifth item</div>");
expect(clipboard.html).toContain("A hard break<br>");
expect(clipboard.html).toContain(
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.',
);
expect(clipboard.html).toContain(
'<a href="https://docs.example.com/" title="Reference">docs</a>',
);
expect(clipboard.html).toContain(
'<a href="https://autolink.example.com/">https://autolink.example.com</a>',
);
expect(clipboard.html).toContain(
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
);
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
expect(clipboard.html).not.toContain(
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
);
expect(clipboard.html).toContain('<code class="language-typescript">');
expect(clipboard.html).toContain(
'<pre><code class="language-text">before\n```\nafter\n</code></pre>',
);
expect(clipboard.html).toContain("<table>");
expect(clipboard.html).toContain('<td style="text-align:left">Current | active</td>');
expect(clipboard.html.match(/<td\b/g)).toHaveLength(3);
expect(clipboard.html).toContain("<s>obsolete</s>");
expect(clipboard.html).toContain('<th style="text-align:left">Left</th>');
expect(clipboard.html).toContain('<th style="text-align:right">Right</th>');
expect(clipboard.html).toContain('<th style="text-align:center">Center</th>');
await doubleClickAssistantMarkdownText(page, "code", "apply_patch");
await copySelection(page);
const doubleClickedCodeClipboard = await readRichClipboard(page);
expect(doubleClickedCodeClipboard.plainText).toBe("apply_patch");
expect(doubleClickedCodeClipboard.html).not.toContain("<code>");
await doubleClickAssistantMarkdownText(page, "strong", "strong prose");
await copySelection(page);
const doubleClickedBoldClipboard = await readRichClipboard(page);
expect(doubleClickedBoldClipboard.plainText).toBe("strong");
expect(doubleClickedBoldClipboard.html).not.toContain("<strong>");
await selectAssistantTextRange(
page,
"P1 — High-value UI behavior",
"Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
);
await copySelection(page);
const headingAndListClipboard = await readRichClipboard(page);
expect(headingAndListClipboard.plainText).toBe(
[
"P1 — High-value UI behavior",
"===========================",
"",
"5. Claude model and thinking preferences",
" - Switch Claude models and confirm each model retains its own thinking choice.",
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
" - Confirm Opus 5 exposes the Fast toggle.",
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
].join("\n"),
);
expect(headingAndListClipboard.html).toMatch(
/<h1>P1 — High-value UI behavior<\/h1>\s*<div>5\. Claude model and thinking preferences\s*<\/div><div>- Switch Claude models/,
);
await selectAssistantText(page, "First");
await copySelection(page);
const partialClipboard = await readRichClipboard(page);
expect(partialClipboard.plainText).toBe("First");
expect(partialClipboard.html).toContain("<p>First</p>");
await selectAssistantTextRange(page, "Direct matches:", "repeated sandbox setup.");
await copySelection(page);
const paragraphAndListClipboard = await readRichClipboard(page);
expect(paragraphAndListClipboard.plainText).toBe(
[
"Direct matches:",
"",
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
"",
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
].join("\n"),
);
expect(paragraphAndListClipboard.html).not.toContain("<ul>");
expect(paragraphAndListClipboard.html).not.toContain("<li>");
expect(paragraphAndListClipboard.html).toContain(
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
);
await selectAssistantListItemFromMarker(page, "ul", "First issue", "failure.");
await copySelection(page);
const draggedBulletClipboard = await readRichClipboard(page);
expect(draggedBulletClipboard.plainText).toBe(
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
);
expect(draggedBulletClipboard.html).toContain(
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
);
await selectAssistantListItemFromMarker(page, "ol", "Sixth item", "Sixth item");
await copySelection(page);
const draggedNumberClipboard = await readRichClipboard(page);
expect(draggedNumberClipboard.plainText).toBe("6. Sixth item");
expect(draggedNumberClipboard.html).toContain("<div>6. Sixth item</div>");
await selectAssistantText(page, "docs");
await copySelection(page);
const titledLinkClipboard = await readRichClipboard(page);
expect(titledLinkClipboard.plainText).toBe("docs");
expect(titledLinkClipboard.html).not.toContain("<a ");
await selectAssistantText(page, "https://autolink.example.com");
await copySelection(page);
const autolinkClipboard = await readRichClipboard(page);
expect(autolinkClipboard.plainText).toBe("https://autolink.example.com");
await selectAssistantText(page, "workspace file");
await copySelection(page);
const fileLinkClipboard = await readRichClipboard(page);
expect(fileLinkClipboard.plainText).toBe("workspace file");
expect(fileLinkClipboard.html).not.toContain("<a ");
await selectAssistantTextRange(page, "Seventh item", "Eighth item");
await copySelection(page);
const midListClipboard = await readRichClipboard(page);
expect(midListClipboard.plainText).toBe("Seventh item\n\n8. Eighth item");
expect(midListClipboard.html).toContain("<div>8. Eighth item</div>");
await selectAssistantTextRange(page, "Inner eight", "Inner nine");
await copySelection(page);
const nestedListClipboard = await readRichClipboard(page);
expect(nestedListClipboard.plainText).toBe("Inner eight\n\n9. Inner nine");
expect(nestedListClipboard.html).toContain("<div>9. Inner nine</div>");
await selectAssistantTextRange(page, "Seventh item", "Nested list:");
await copySelection(page);
const crossBlockClipboard = await readRichClipboard(page);
expect(crossBlockClipboard.plainText).toBe("Seventh item\n\n8. Eighth item\n\nNested list:");
expect(crossBlockClipboard.html).toContain("<div>8. Eighth item</div>");
await selectAssistantText(page, "Current");
await copySelection(page);
const partialTableClipboard = await readRichClipboard(page);
expect(partialTableClipboard.plainText).toBe("Current");
expect(partialTableClipboard.html).toContain("<p>Current</p>");
expect(partialTableClipboard.html).not.toContain("&lt;table");
await selectAssistantTextRange(page, "Right", "ready");
await copySelection(page);
const columnSliceClipboard = await readRichClipboard(page);
expect(columnSliceClipboard.plainText).toContain("ready");
expect(columnSliceClipboard.html).not.toContain("<table>");
expect(columnSliceClipboard.html).toContain("ready");
expect(columnSliceClipboard.html).toContain("<s>obsolete</s>");
// A partial selection loses its `pre` wrapper, so the code reaches Turndown as
// prose unless it is diverted first: the line break and the indentation go, and
// Markdown-significant characters get escaped.
await selectAssistantAcrossCodeLines(page, "const", "return");
await copySelection(page);
const codeLinesClipboard = await readRichClipboard(page);
expect(codeLinesClipboard.plainText).toBe('const answer = "yes";\n return');
expect(codeLinesClipboard.html).toContain('<pre><code class="language-typescript">');
// Selecting inside code expresses an intent to copy code, even when the selection
// contains every character in the block.
await selectAssistantElement(
page,
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
);
await copySelection(page);
const completeCodeClipboard = await readRichClipboard(page);
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
// Crossing the block boundary expresses an intent to carry its Markdown structure.
await selectAssistantAcrossCodeLines(page, "const", "After code.");
await copySelection(page);
const crossedCodeClipboard = await readRichClipboard(page);
expect(crossedCodeClipboard.plainText).toBe(
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
);
// Selecting a whole line runs off its end into the newline node. A trailing
// newline auto-executes the line when it is pasted into a terminal.
await selectAssistantAcrossCodeLines(page, "const", " ");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe('const answer = "yes";');
// The block's own Copy button had the same hazard: a Markdown fence body ends in
// a newline, and the button copied it raw.
const typescriptFence = assistantMessage.locator('[data-paseo-markdown-language="typescript"]');
// The button is opacity 0 / pointerEvents none until the fence is hovered.
await typescriptFence.hover();
await typescriptFence.locator("[data-paseo-markdown-ignore]").click();
expect(await readPlainClipboard(page)).toBe('const answer = "yes";\n return answer;');
// A blank line before the closing fence leaves the body ending in two newlines,
// so stripping only the terminal one still hands the terminal an executable line.
const bashFence = assistantMessage.locator('[data-paseo-markdown-language="bash"]');
await bashFence.hover();
await bashFence.locator("[data-paseo-markdown-ignore]").click();
expect(await readPlainClipboard(page)).toBe("echo trailing");
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,185 @@
import { existsSync } from "node:fs";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
clickSessionRow,
expectSessionRowArchived,
openSessions,
} from "../support/helpers/archive-tab";
import {
cloneGithubRepoDefaultBranchOnly,
createTempGithubRepo,
} from "../support/helpers/github-fixtures";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
} from "../support/helpers/new-workspace";
import { connectSeedClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
test.describe("Auto-archive after pull request merge", () => {
test.describe.configure({ retries: 0, timeout: 180_000 });
test("manual restore latches and keeps a merged pull request workspace active", async ({
page,
}) => {
const scenario = await createMergedPullRequestScenario();
try {
await scenario.autoArchive();
await openArchivedWorkspaceFromHistory(page, scenario.agentTitle);
await restoreWorkspace(page, scenario.workspaceId);
await scenario.refreshMergedPullRequest();
await scenario.expectWorkspaceRemainsActive();
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: scenario.workspaceId,
});
} finally {
await scenario.cleanup();
}
});
});
interface MergedPullRequestScenario {
agentTitle: string;
workspaceId: string;
autoArchive(): Promise<void>;
refreshMergedPullRequest(): Promise<void>;
expectWorkspaceRemainsActive(): Promise<void>;
cleanup(): Promise<void>;
}
async function createMergedPullRequestScenario(): Promise<MergedPullRequestScenario> {
const cleanups: Array<() => Promise<unknown>> = [];
try {
const repo = await createTempGithubRepo({
category: "auto-archive-latch",
prs: [{ title: "Auto-archive latch", state: "merged" }],
});
cleanups.push(() => repo.cleanup());
const checkout = await cloneGithubRepoDefaultBranchOnly(repo);
cleanups.push(() => checkout.cleanup());
const workspaceClient = await connectNewWorkspaceDaemonClient();
cleanups.push(() => workspaceClient.close());
const agentClient = await connectSeedClient();
cleanups.push(() => agentClient.close());
const previousConfig = await workspaceClient.getDaemonConfig();
await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: false });
cleanups.push(() =>
workspaceClient.patchDaemonConfig({
autoArchiveAfterMerge: previousConfig.config.autoArchiveAfterMerge,
}),
);
const pullRequest = repo.prs[0];
if (!pullRequest) {
throw new Error("Expected the merged pull request fixture");
}
const created = await workspaceClient.createPaseoWorktree({
cwd: checkout.path,
action: "checkout",
checkoutSource: {
kind: "change_request",
forge: "github",
number: pullRequest.number,
},
worktreeSlug: `auto-archive-latch-${Date.now()}`,
});
if (!created.workspace || created.error) {
throw new Error(created.error ?? "Failed to create merged pull request workspace");
}
const workspace = created.workspace;
cleanups.push(() => workspaceClient.removeProject(workspace.projectId));
cleanups.push(() => archiveWorkspaceFromDaemon(workspaceClient, workspace.workspaceDirectory));
const agentTitle = `Auto-archive latch ${Date.now()}`;
const agent = await agentClient.createAgent({
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
cwd: workspace.workspaceDirectory,
workspaceId: workspace.id,
title: agentTitle,
});
await agentClient.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle",
30_000,
);
return {
agentTitle,
workspaceId: workspace.id,
autoArchive: async () => {
await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: true });
await workspaceClient.checkoutRefresh(workspace.workspaceDirectory);
await expect
.poll(() => agentClient.fetchAgent({ agentId: agent.id }), { timeout: 30_000 })
.toMatchObject({ agent: { archivedAt: expect.any(String) } });
await expect
.poll(() => existsSync(workspace.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
},
refreshMergedPullRequest: async () => {
const refresh = await workspaceClient.checkoutRefresh(workspace.workspaceDirectory);
if (!refresh.success) {
throw new Error(`Failed to refresh restored workspace: ${String(refresh.error)}`);
}
},
expectWorkspaceRemainsActive: async () => {
const observationDeadline = Date.now() + 5_000;
await expect
.poll(
async () => {
const descriptor = (await workspaceClient.fetchWorkspaces()).entries.find(
(entry) => entry.id === workspace.id,
);
if (!descriptor) {
return "archived";
}
return Date.now() >= observationDeadline ? "active" : "observing";
},
{ timeout: 10_000, intervals: [100, 250, 500] },
)
.toBe("active");
},
cleanup: () => runScenarioCleanups(cleanups),
};
} catch (error) {
await runScenarioCleanups(cleanups);
throw error;
}
}
async function runScenarioCleanups(cleanups: Array<() => Promise<unknown>>): Promise<void> {
while (cleanups.length > 0) {
await cleanups
.pop()?.()
.catch(() => undefined);
}
}
async function openArchivedWorkspaceFromHistory(page: Page, agentTitle: string): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowArchived(page, agentTitle);
await clickSessionRow(page, agentTitle);
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
}
async function restoreWorkspace(page: Page, workspaceId: string): Promise<void> {
const action = page.getByTestId("workspace-recovery-action");
await expect(action).toHaveText("Restore");
await action.click();
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
}
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
@@ -1,15 +1,18 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
expectNoBranchSwitcherInWorkspaceHeader,
expectWorkspaceBranch,
openChangesPanel,
switchBranchFromChangesPanel,
} from "./helpers/branch-switcher";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { readWorktreeBranchInfo } from "./helpers/workspace";
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
} from "../support/helpers/branch-switcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { readWorktreeBranchInfo } from "../support/helpers/workspace";
import {
switchWorkspaceViaSidebar,
waitForSidebarHydration,
} from "../support/helpers/workspace-ui";
async function renameWorkspaceViaSidebar(
page: Page,
@@ -1,12 +1,14 @@
import { unlink, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
import { test, expect } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { type Locator, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
@@ -15,6 +17,9 @@ interface DirtyWorkspace {
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
includeNestedFolders?: boolean;
includeRenamedFile?: boolean;
includeUntrackedFile?: boolean;
}
interface CleanupTask {
@@ -23,8 +28,45 @@ interface CleanupTask {
const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
async function failNextDiscardRequest(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message) as {
message?: { type?: string; cwd?: string; requestId?: string };
};
if (envelope.message?.type === "checkout.discard_changes.request") {
browserSocket.send(
JSON.stringify({
type: "session",
message: {
type: "checkout.discard_changes.response",
payload: {
cwd: envelope.message.cwd,
success: false,
error: { code: "UNKNOWN", message: "Injected revert failure" },
requestId: envelope.message.requestId,
},
},
}),
);
return;
}
}
serverSocket.send(message);
});
serverSocket.onMessage((message) => browserSocket.send(message));
});
}
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
function diffGroups(scope: Page | Locator, fileIndex = 0): Locator {
return scope.getByTestId(new RegExp(`^diff-file-${fileIndex}-group-\\d+$`));
}
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
@@ -201,47 +243,218 @@ test.afterEach(async () => {
}
});
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 9);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectDiffCodeFontSize(page, 9);
await expectVisibleDiffRowsAligned(page);
await expectDiffCodeTextAlignedWithGutterText(page, [
{
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
},
{ codeText: "return next;", lineNumber: "55" },
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
]);
await expectHoverCommentButtonAlignedWithCodeLine(page, {
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
test("changes file actions open below the right-click without a reserved kebab", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
await expect(deletedFileName).toHaveCSS("user-select", "none");
await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
await expect(page.getByText("Copy path")).toBeVisible();
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const fileRow = page.getByTestId("diff-file-0-toggle");
const fileRowBounds = await fileRow.boundingBox();
expect(fileRowBounds).not.toBeNull();
await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
expect(menuBounds).not.toBeNull();
expect(menuBounds!.x).toBeCloseTo(fileRowBounds!.x + 80, 0);
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("changes context menus duplicate files and folders", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-duplicate").click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8"))
.toBe(AFTER);
await page.getByTestId("changes-toggle-view-mode").click();
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-duplicate").click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER);
});
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-collapse-folder").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0);
await page.getByTestId("diff-folder-src/zz-folder-toggle").click();
await expect(page.getByText("root.ts", { exact: true })).toBeVisible();
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0);
await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
await expect(page.getByText("changed.ts", { exact: true })).toBeVisible();
});
test("changes context menus expose folder revert and restore a file after confirmation", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
const folderRevert = page.getByTestId("diff-folder-src-revert");
await expect(folderRevert).toBeVisible();
const revertLabelColor = await folderRevert
.getByText("Discard changes", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.dismiss();
resolve(message);
});
});
await page.getByTestId("diff-file-0-revert").click();
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER);
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.accept();
resolve(message);
});
});
await page.getByTestId("diff-file-0-revert").click();
expect(await confirmation).toContain("src/use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 });
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(BEFORE);
});
test("discarding a staged rename restores its source path", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const renamedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-renamed.ts" });
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
await renamedToggle.click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByTestId(`${rowTestId}-revert`).click();
await confirmation;
await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8"))
.toBe("export const renamed = true;\n");
});
test("discarding an untracked file removes it from the working tree", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const untrackedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-untracked.txt" });
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
await untrackedToggle.click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByTestId(`${rowTestId}-revert`).click();
await confirmation;
await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(
readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"),
).rejects.toThrow();
});
test("shows a revert error returned by the daemon", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await failNextDiscardRequest(page);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByTestId("diff-file-0-revert").click();
await confirmation;
await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER);
});
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
@@ -256,12 +469,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await expect(diffGroups(visiblePanel)).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
@@ -277,14 +490,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await expect(visiblePanel.getByTestId(/^diff-file-\d+-group-\d+$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -303,17 +514,13 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await expect(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
@@ -339,12 +546,44 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
"user-select",
"none",
);
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2);
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
const folderLabelBounds = await page
.getByTestId("diff-folder-src")
.getByText("src", { exact: true })
.boundingBox();
const fileLabelBounds = await page
.getByTestId("diff-file-0")
.getByText("use-mounted-tab-set.ts", { exact: true })
.boundingBox();
expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0);
const folderToggle = page.getByTestId("diff-folder-src-toggle");
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(page.getByTestId("diff-file-0")).toBeVisible();
const fileToggle = page.getByTestId("diff-file-0-toggle");
await fileToggle.click({ button: "right" });
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(page).first()).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -353,56 +592,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expectFlatFileList(page);
});
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
@@ -415,7 +605,90 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
await expectDiffCodeFontSize(page, 18);
await expectVisibleDiffRowsShareTypography(page);
await expectVisibleDiffRowsAligned(page);
});
const LARGE_DIFF_WIDE_LINE_INDEX = 900;
async function createWorkspaceWithLargeUnwrappedDiff(): Promise<DirtyWorkspace> {
const workspace = await createWorkspaceWithMountedTabDiff();
const addedLines = Array.from({ length: 1_200 }, (_, index) => {
if (index === 0) return `\t\t\t\tconst tabbed_${index} = "tabbed";`;
if (index === LARGE_DIFF_WIDE_LINE_INDEX) {
return `const wide_${index} = "${"界".repeat(80)}";`;
}
return `const generated_${index} = ${index};`;
}).join("\n");
await writeFile(
path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"),
`${AFTER}${addedLines}\n`,
);
return workspace;
}
async function scrollToWideDiffLine(page: Page): Promise<Locator> {
await page.getByTestId("git-diff-scroll").evaluate((element, lineIndex) => {
element.scrollTop = lineIndex * 18;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, LARGE_DIFF_WIDE_LINE_INDEX);
const wideLine = page.getByText(new RegExp(`const wide_${LARGE_DIFF_WIDE_LINE_INDEX}`));
await expect(wideLine).toHaveCount(1);
return wideLine;
}
test("unwrapped virtualized rows use one horizontal file surface", async ({ page }) => {
const workspace = await createWorkspaceWithLargeUnwrappedDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const horizontalSurface = page.getByTestId("diff-horizontal-scroll");
await expect(horizontalSurface).toHaveCount(1);
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
const codeBefore = await page.getByTestId("diff-code-row-0").boundingBox();
const gutterBefore = await page.getByTestId("diff-gutter-row-0").boundingBox();
expect(codeBefore).not.toBeNull();
expect(gutterBefore).not.toBeNull();
await horizontalSurface.evaluate((element, left) => {
element.scrollLeft = left;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, 120);
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
await expect
.poll(async () => (await page.getByTestId("diff-code-row-0").boundingBox())?.x ?? 0)
.toBeLessThanOrEqual(codeBefore!.x - 119);
await expect
.poll(async () => (await page.getByTestId("diff-gutter-row-0").boundingBox())?.x ?? 0)
.toBeCloseTo(gutterBefore!.x, 0);
const initiallyMountedGroups = await readMountedDiffGroupIds(page);
await page.getByTestId("git-diff-scroll").evaluate((element) => {
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => {
const mountedGroups = await readMountedDiffGroupIds(page);
return mountedGroups.some((group) => !initiallyMountedGroups.includes(group));
})
.toBe(true);
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
const wideLine = await scrollToWideDiffLine(page);
await horizontalSurface.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => {
const surfaceBounds = await horizontalSurface.boundingBox();
const wideTextRight = await wideLine.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
return range.getBoundingClientRect().right;
});
if (!surfaceBounds) return false;
return wideTextRight <= surfaceBounds.x + surfaceBounds.width + 1;
})
.toBe(true);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
@@ -443,6 +716,12 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
);
}
async function readMountedDiffGroupIds(page: Page): Promise<(string | null)[]> {
return diffGroups(page).evaluateAll((groups) =>
groups.map((group) => group.getAttribute("data-testid")),
);
}
async function useUnwrappedDiffLines(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
@@ -476,43 +755,37 @@ async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<voi
.toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
}
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
}
async function readVisibleDiffRowGeometry(page: Page): Promise<{
maxDelta: number;
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
rows: {
index: number;
gutterTop: number;
codeTop: number;
delta: number;
gutterLineHeight: number;
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
return diffGroups(page).evaluateAll((groups) => {
if (groups.length === 0) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
groups.flatMap((group) =>
Array.from(group.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = row.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
}),
);
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
@@ -528,7 +801,6 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
index: code.index,
gutterTop: gutter.top,
codeTop: code.top,
delta: Math.abs(code.top - gutter.top),
gutterLineHeight: gutter.lineHeight,
codeLineHeight: code.lineHeight,
};
@@ -536,7 +808,6 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
return {
maxDelta: Math.max(...rows.map((row) => row.delta)),
mismatchedTypography: rows
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
.map((row) => ({
@@ -556,7 +827,16 @@ async function createWorkspaceWithMountedTabDiff(
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
const repo = await createTempGitRepo("diff-row-alignment-", { files });
if (options.includeRenamedFile) {
files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" });
}
if (options.includeNestedFolders) {
files.push(
{ path: "src/zz-folder/root.ts", content: "export const root = 1;\n" },
{ path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" },
);
}
const repo = await createTempGitRepo("changes-pane-", { files });
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
@@ -566,9 +846,24 @@ async function createWorkspaceWithMountedTabDiff(
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeUntrackedFile) {
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
}
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
if (options.includeRenamedFile) {
execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], {
cwd: repo.path,
});
}
if (options.includeNestedFolders) {
await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n");
await writeFile(
path.join(repo.path, "src/zz-folder/nested/changed.ts"),
"export const nested = 2;\n",
);
}
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
@@ -593,23 +888,8 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
@@ -648,98 +928,9 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
}
async function expectDiffCodeTextAlignedWithGutterText(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<void> {
const geometries = await readDiffTextGeometry(page, lines);
for (const geometry of geometries) {
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
}
}
async function expectHoverCommentButtonAlignedWithCodeLine(
page: Page,
line: { codeText: string; lineNumber: string },
): Promise<void> {
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
if (!target) {
throw new Error(`Could not find target line ${line.lineNumber}`);
}
await page.getByTestId(`diff-code-row-${target.index}`).hover();
const geometry = await page
.getByTestId(`diff-gutter-action-${target.index}`)
.evaluate((action, expectedCodeCenterY) => {
const rect = action.getBoundingClientRect();
return {
actionCenterY: rect.top + rect.height / 2,
codeCenterY: expectedCodeCenterY,
};
}, target.codeCenterY);
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
}
async function readDiffTextGeometry(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
> {
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
if (!root) {
throw new Error("Changes panel is not mounted");
}
const readIndexedElements = (prefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
(element) => {
const testId = element.getAttribute("data-testid") ?? "";
return { index: Number(testId.slice(prefix.length)), element };
},
);
const gutterTexts = readIndexedElements("diff-gutter-text-");
const codeTexts = readIndexedElements("diff-code-text-");
return targets.map((target) => {
const gutter = gutterTexts.find(
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
);
if (!gutter) {
throw new Error(`Could not find gutter line ${target.lineNumber}`);
}
const code = codeTexts.find(
({ index, element }) =>
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
);
if (!code) {
throw new Error(`Could not find code row ${target.codeText}`);
}
const codeRect = code.element.getBoundingClientRect();
const gutterRect = gutter.element.getBoundingClientRect();
return {
index: gutter.index,
codeText: target.codeText,
codeTop: codeRect.top,
gutterTop: gutterRect.top,
codeCenterY: codeRect.top + codeRect.height / 2,
};
});
}, lines);
const groups = diffGroups(page);
await expect.poll(() => groups.count()).toBeGreaterThan(1);
const lastMountedGroup = groups.last();
await lastMountedGroup.scrollIntoViewIfNeeded();
await expect(lastMountedGroup.locator('[data-testid^="diff-code-row-"]').last()).toBeVisible();
}
@@ -0,0 +1,239 @@
import { expect, test } from "../support/fixtures";
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
import {
clickChatOutlineRowEdge,
disableChatOutlineFromAppearance,
expectActiveChatOutlinePrompt,
expectChatOutlinePreview,
expectChatOutlinePrompts,
expectChatOutlinePromptToRemainBare,
expectLiveTurnPromptAboveFoldAndActive,
expectNoChatOutlinePreviewWhileCrossingToSidebar,
expectNoChatOutline,
expectNoChatOutlinePreview,
expectOneActiveChatOutlinePrompt,
focusChatOutlinePrompt,
hoverChatOutlinePrompt,
chatOutlineRail,
movePointerOffChatOutline,
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
} from "../support/helpers/chat-outline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
expectTimelinePromptLandedBelowTop,
expectTimelinePromptVisible,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
scrollTimelineToNewestLoadedEdge,
scrollTimelineToOldestLoadedEdge,
seedLongMockAgentTimeline,
type LongTimelineAgent,
} from "../support/helpers/timeline-pagination";
// Wide enough that the timeline panel clears the rail's MIN_PANEL_WIDTH with room
// to spare. At 1280 the panel measures 960, which sits too close to the threshold
// for chrome-width changes elsewhere to stay out of these tests.
const WIDE_VIEWPORT = { width: 1440, height: 900 };
const LOADED_TURNS = 16;
test.describe("desktop chat outline", () => {
test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const jumps = await trackPromptJumpRequests(page, agent.agentId);
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
const rail = chatOutlineRail(page);
await expectChatOutlinePrompts(page, 80);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await rail.getByRole("tab").nth(39).click();
await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
await expect.poll(() => jumps.requests()).toHaveLength(1);
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
await page.getByTestId("scroll-to-bottom-button").click();
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requestsBeforeLoadedJump = jumps.requests().length;
await rail.getByRole("tab").last().click();
await expectTimelinePromptVisible(page, agent.newestPrompt);
expect(jumps.requests()).toHaveLength(requestsBeforeLoadedJump);
await rail.getByRole("tab").first().click();
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await agent.client.sendAgentMessage(agent.agentId, "live prompt while viewing old history");
await agent.client.waitForFinish(agent.agentId, 15_000);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
test.describe("with the whole conversation loaded", () => {
let agent: LongTimelineAgent;
test.beforeAll(async () => {
test.setTimeout(120_000);
agent = await seedLongMockAgentTimeline({ turns: LOADED_TURNS });
});
test.afterAll(async () => {
await agent.cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, LOADED_TURNS);
});
test("previews the prompt under the pointer, anywhere across its row", async ({ page }) => {
await hoverChatOutlinePrompt(page, 2);
await expectChatOutlinePreview(page, agent.prompts[1]);
await pointAtChatOutlineRowEdge(page, 7);
await expectChatOutlinePreview(page, agent.prompts[6]);
await movePointerOffChatOutline(page);
await expectNoChatOutlinePreview(page);
});
test("does not preview while crossing the rail toward the sidebar", async ({ page }) => {
await expectNoChatOutlinePreviewWhileCrossingToSidebar(page);
});
test("closes the preview when the pointer leaves after a jump", async ({ page }) => {
await clickChatOutlineRowEdge(page, 4);
await movePointerOffChatOutline(page);
await expectNoChatOutlinePreview(page);
});
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
await focusChatOutlinePrompt(page, 1);
await expectChatOutlinePreview(page, agent.prompts[0]);
await pressEnterOnFocusedPrompt(page);
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
await expectActiveChatOutlinePrompt(page, 1);
});
test("moves the active mark as the reader jumps and scrolls", async ({ page }) => {
await expectOneActiveChatOutlinePrompt(page);
await clickChatOutlineRowEdge(page, 4);
await expectTimelinePromptVisible(page, agent.prompts[3]);
await expectActiveChatOutlinePrompt(page, 4);
await scrollTimelineToOldestLoadedEdge(page);
await expectActiveChatOutlinePrompt(page, 1);
});
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
await clickChatOutlineRowEdge(page, 4);
await expectChatOutlinePromptToRemainBare(page, 4);
});
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
page,
}) => {
await clickChatOutlineRowEdge(page, LOADED_TURNS);
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
});
test("keeps the current prompt active throughout a long live assistant turn", async ({
page,
}) => {
const prompt = "chat outline live turn reading position";
await agent.client.sendAgentMessage(agent.agentId, prompt);
try {
await expectLiveTurnPromptAboveFoldAndActive(page, prompt, LOADED_TURNS + 1);
} finally {
await agent.client.waitForFinish(agent.agentId, 15_000);
}
});
});
test.describe("with virtualized loaded history", () => {
let agent: LongTimelineAgent;
test.beforeAll(async () => {
test.setTimeout(120_000);
agent = await seedLongMockAgentTimeline({ turns: 80 });
});
test.afterAll(async () => {
await agent.cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize(WIDE_VIEWPORT);
});
test("pins a loaded prompt inside the virtualized block without fetching", async ({ page }) => {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await scrollThroughOlderHistoryPages(page, 2, history);
await scrollTimelineToNewestLoadedEdge(page);
const requestsBeforeJump = history.requestCount();
await clickChatOutlineRowEdge(page, 30);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
expect(history.requestCount()).toBe(requestsBeforeJump);
});
test("pins a newer loaded prompt when jumping downward from old history", async ({ page }) => {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await scrollThroughOlderHistoryPages(page, 2, history);
await scrollTimelineToNewestLoadedEdge(page);
await clickChatOutlineRowEdge(page, 30);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
await clickChatOutlineRowEdge(page, 60);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
});
});
test("hides the rail when a split makes its panel narrow", async ({ page }) => {
const agent = await seedLongMockAgentTimeline({ turns: 2 });
try {
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await splitCurrentPanelRight(page);
await expectNoChatOutline(page);
} finally {
await agent.cleanup();
}
});
test("can be disabled from Appearance settings", async ({ page }) => {
const agent = await seedLongMockAgentTimeline({ turns: 2 });
try {
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await disableChatOutlineFromAppearance(page);
await expectNoChatOutline(page);
} finally {
await agent.cleanup();
}
});
});
@@ -0,0 +1,66 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { submitMessage } from "../support/helpers/composer";
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "../support/helpers/rewind-flow";
import { openSubagentsTrack } from "../support/helpers/subagents";
process.env.CLAUDE_CODE_WORKFLOWS = "1";
const WORKFLOW_SCRIPT = path.resolve(__dirname, "../fixtures/claude-workflow/inspect-project.js");
const REPO_ROOT = path.resolve(__dirname, "../../../..");
const DESCRIPTION = "Inspect Paseo and explain what the project does";
test.describe("real Claude project-inspection workflow", () => {
test.setTimeout(300_000);
test("shows the workflow's actual report in the generic detail pane", async ({
page,
}, testInfo) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-project-inspection-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await submitMessage(
page,
`Use Claude Code's Workflow tool exactly once with scriptPath ${JSON.stringify(
WORKFLOW_SCRIPT,
)} and args ${JSON.stringify({ repoPath: REPO_ROOT })}. Wait for completion, then reply with exactly PROJECT_INSPECTION_DONE.`,
);
await expect(page.getByTestId("subagents-track-header")).toBeVisible({ timeout: 60_000 });
await openSubagentsTrack(page);
const row = page
.getByTestId(/^subagents-track-row-/)
.filter({ has: page.getByText(DESCRIPTION, { exact: true }) });
await expect(row).toBeVisible({ timeout: 60_000 });
await expect(row.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0, {
timeout: 180_000,
});
await expect(
page.getByTestId("assistant-message").filter({ hasText: "PROJECT_INSPECTION_DONE" }).last(),
).toBeVisible({ timeout: 180_000 });
await row.click();
const panel = page.getByTestId("provider-subagent-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
for (const heading of [
"What Paseo is",
"Who it is for",
"How it works",
"Repository structure",
]) {
await expect(panel.getByText(heading, { exact: true })).toBeVisible();
}
await expect(panel.getByText("export const meta", { exact: false })).toHaveCount(0);
await expect(
panel.getByText("Inspect the Paseo repository at", { exact: false }),
).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("project-workflow-pane.png") });
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -0,0 +1,55 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "../support/fixtures";
import {
askClaudeToRunWorkflow,
expectSingleWorkflowParentCard,
expectWorkflowCompleted,
expectWorkflowRunning,
openWorkflowTimeline,
releaseWorkflow,
} from "../support/helpers/claude-workflow";
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "../support/helpers/rewind-flow";
// Claude Code currently gates the Workflow tool behind its own rollout. The real-provider test
// opts in explicitly so it tests Paseo's integration rather than the account's rollout cohort.
process.env.CLAUDE_CODE_WORKFLOWS = "1";
const WORKFLOW_SCRIPT = path.resolve(__dirname, "../fixtures/claude-workflow/one-child.js");
test.describe("real Claude workflow subagent row", () => {
test.setTimeout(300_000);
test("shows a real Claude workflow running and completed through the generic subagent UI", async ({
page,
}, testInfo) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-claude-workflow-row-")));
const gatePath = path.join(cwd, "release-workflow");
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await test.step("ask Claude to run the workflow", async () => {
await askClaudeToRunWorkflow(handle!, WORKFLOW_SCRIPT, gatePath);
await expectWorkflowRunning(page);
await page.screenshot({ path: testInfo.outputPath("workflow-running.png") });
releaseWorkflow(gatePath);
});
await test.step("see the workflow finish without leaving a running row", async () => {
await expectWorkflowCompleted(page);
await expectSingleWorkflowParentCard(page);
await page.screenshot({ path: testInfo.outputPath("workflow-completed.png") });
});
await test.step("open the workflow through the existing provider-subagent pane", async () => {
await openWorkflowTimeline(page);
await page.screenshot({ path: testInfo.outputPath("workflow-timeline.png") });
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -1,12 +1,12 @@
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expect, test, type Page } from "../support/fixtures";
import { composerLocator, expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectSessionRowArchived,
expectWorkspaceTabHidden,
expectWorkspaceTabVisible,
openSessions,
} from "./helpers/archive-tab";
} from "../support/helpers/archive-tab";
interface SlashCommandScenario {
agentId: string;
@@ -1,6 +1,6 @@
import { expect, test } from "./fixtures";
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expect, test } from "../support/fixtures";
import { allowPermission, waitForPermissionPrompt } from "../support/helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.describe("Codex plan approval", () => {
test("shows a single actionable plan panel and removes it after implementation starts", async ({
@@ -0,0 +1,110 @@
import { test } from "../support/fixtures";
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
submitDraftAgent,
waitForDraftComposer,
} from "../support/helpers/command-center-agent-controls";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise<void> {
await page.addInitScript(
({ preferencesKey, serverId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId,
provider: "mock",
providerPreferences: {
mock: {
model: "five-minute-stream",
mode: "load-test",
},
},
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() },
);
}
test.describe("Command Center agent controls", () => {
test.describe.configure({ timeout: 180_000 });
test("changes a running agent setting and preserves its selected row", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-live-controls-",
title: "Command Center live controls",
model: "five-minute-stream",
});
try {
// A stale runtime mode makes selecting the provider's supported mode
// exercise the live setting RPC instead of the selected-row no-op.
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "load test",
choice: "Mode › Load test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
await openCommandCenter(page);
await expectCommandCenterAgentControlSelected({
page,
query: "load test",
choice: "Mode › Load test",
});
} finally {
await workspace.cleanup();
}
});
test("applies draft model and setting choices to the created agent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
await seedMockDraftPreferences(page);
try {
await openDraftComposer(page, workspace.workspaceId);
await applyCommandCenterAgentControls(page, DRAFT_AGENT_CONTROL_CHOICES);
await closeCommandCenter(page);
await submitDraftAgent(page, "Create an agent with Command Center draft choices");
await expectFocusedAgentControls(page, DRAFT_AGENT_CONTROL_CHOICES);
await expectWorkspaceAgentConfiguration(workspace, {
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
});
const DRAFT_AGENT_CONTROL_CHOICES = [
{ query: "ten second stream", choice: "Model › Mock Load Test › Ten second stream" },
{ query: "load test", choice: "Mode › Load test" },
] as const;
async function openDraftComposer(page: import("@playwright/test").Page, workspaceId: string) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await waitForDraftComposer(page);
}
@@ -0,0 +1,139 @@
import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import {
delayDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
} from "../support/helpers/command-center-file-search";
import { expectFileTabOpen } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const FILE_PATH =
"packages/app/src/command-center/features/workspace-file-search/layout-stability/needle-layout-stability-command-center.tsx";
const FILE_NAME = "needle-layout-stability-command-center.tsx";
const FILE_DIRECTORY =
"packages/app/src/command-center/features/workspace-file-search/layout-stability";
function paseoSizedFiles(): Array<{ path: string; content: string }> {
const files = Array.from({ length: 120 }, (_, index) => ({
path: `packages/app/src/features/feature-${String(index).padStart(3, "0")}/index.ts`,
content: `export const feature${index} = ${index};\n`,
}));
files.push({ path: FILE_PATH, content: "export const layoutNeedle = true;\n" });
return files;
}
async function seedMatchingWorkspaces(count: number): Promise<SeededWorkspace[]> {
return Promise.all(
Array.from({ length: count }, (_, index) =>
seedWorkspace({
repoPrefix: `command-center-file-search-error-${String(index).padStart(2, "0")}-`,
title: `Home failure ${String(index).padStart(2, "0")}`,
}),
),
);
}
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("workspace file search stays geometrically stable through delayed loading and results", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedWorkspace({
repoPrefix: "command-center-file-search-",
title: "Paseo-shaped file search",
repo: { files: paseoSizedFiles() },
});
try {
await delayDirectorySuggestionResponses(page, 800);
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
await startCommandCenterLayoutObservation(page);
await panel.getByTestId("command-center-input").fill(FILE_NAME);
const row = panel.getByRole("button", {
name: `${FILE_NAME} ${FILE_DIRECTORY}`,
});
await expect(row).toBeVisible({ timeout: 30_000 });
await expectOneLineTruncatedFileResult(row, {
filename: FILE_NAME,
path: FILE_DIRECTORY,
});
await panel.getByTestId("command-center-input").fill("needle-layout");
await page.waitForTimeout(150);
expect(await row.isVisible()).toBe(true);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expectStableCommandCenterLayout(page);
await row.click();
await expectFileTabOpen(page, FILE_PATH);
} finally {
await seeded.cleanup();
}
});
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-file-search-scope-",
title: "Scope chip height",
});
try {
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
const header = panel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
expect(scoped?.height).toBe(unscoped?.height);
} finally {
await seeded.cleanup();
}
});
test("unscoped command search keeps commands visible and reports file-search failures", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedMatchingWorkspaces(9);
try {
await failDirectorySuggestionRequests(page);
await gotoWorkspace(page, seeded[0].workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("home");
await expect(panel.getByRole("button", { name: "Home", exact: true })).toBeVisible();
await expect(panel.getByRole("button", { name: /Home failure 08/ })).toBeVisible();
await expect(panel.getByTestId("command-center-file-search-error")).toHaveText(
"Error: Test file search transport failure.",
);
await expect(panel.getByTestId("command-center-file-search-error")).toBeInViewport({
ratio: 1,
});
await expect(panel.getByText("No matches", { exact: true })).toHaveCount(0);
} finally {
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
}
});
@@ -0,0 +1,58 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { seedWorkspace } from "../support/helpers/seed-client";
const GROUP_BY_STATUS = "Group by status";
const GROUP_BY_PROJECT = "Group by project";
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("group");
const entry = panel.getByText(label, { exact: true });
await expect(entry).toBeVisible({ timeout: 30_000 });
// The entry always names the mode you are not in, so its opposite must never be listed.
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
await entry.click();
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
}
test.describe("Command center sidebar grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" });
try {
await gotoAppShell(page);
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
const statusList = page.getByTestId("sidebar-status-list-scroll");
await expect(projectList).toBeVisible({ timeout: 30_000 });
// Query-only: the grouping entry stays out of the default empty-query list.
const panel = await openCommandCenter(page);
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(panel).not.toBeVisible({ timeout: 30_000 });
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
await expect(statusList).toBeVisible({ timeout: 30_000 });
await expect(projectList).toHaveCount(0);
// The only assertion that catches a flip which never reached persisted storage.
await page.reload();
await expect(statusList).toBeVisible({ timeout: 30_000 });
// Grouping persists, so the run must leave the sidebar back in project mode.
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
await expect(projectList).toBeVisible({ timeout: 30_000 });
await expect(statusList).toHaveCount(0);
} finally {
await seeded.cleanup().catch(() => undefined);
}
});
});
@@ -1,13 +1,13 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { expectAgentTabActive } from "./helpers/launcher";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
@@ -0,0 +1,53 @@
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
expectCommandCenterToRemainAtTheTop,
expectPrimaryCommandCenterActions,
expectTightTwoLineResultSpacing,
expectVisibleKeyboardNavigationNotToScroll,
expectWorkspaceResultsToBeLimitedUntilSearch,
observeCommandCenterScroll,
openCommandCenterWithKeyboard,
} from "../support/helpers/command-center-scroll";
import { seedWorkspace } from "../support/helpers/seed-client";
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("opening the command center keeps its first result fully visible", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-scroll-",
title: "Command Center Scroll 00",
});
try {
for (let index = 1; index <= 8; index += 1) {
const created = await seeded.client.createWorkspace({
source: {
kind: "worktree",
projectId: seeded.projectId,
baseBranch: "main",
worktreeSlug: `command-center-scroll-${index}`,
},
title: `Command Center Scroll ${String(index).padStart(2, "0")}`,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
}
}
await gotoAppShell(page);
await observeCommandCenterScroll(page);
const panel = await openCommandCenterWithKeyboard(page);
await expectCommandCenterToRemainAtTheTop(page);
await expectPrimaryCommandCenterActions(panel);
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
} finally {
await seeded.cleanup();
}
});
@@ -0,0 +1,34 @@
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import {
createTerminalFromCommandCenter,
expectDefaultWorkspaceActions,
expectWorkspaceActionsAvailableThroughSearch,
makeWorkspacePrimaryGitActionCommit,
openWorkspaceFromCommandCenter,
} from "../support/helpers/command-center-workspace-actions";
import { seedWorkspace } from "../support/helpers/seed-client";
test("workspace actions use the Git policy and dispatch through the active workspace", async ({
page,
}) => {
const title = "Command Center Workspace Actions";
const seeded = await seedWorkspace({
repoPrefix: "command-center-workspace-actions-",
title,
});
try {
await makeWorkspacePrimaryGitActionCommit(seeded);
await gotoAppShell(page);
await openWorkspaceFromCommandCenter(page, seeded, title);
const panel = await openCommandCenter(page);
await expectDefaultWorkspaceActions(panel);
await expectWorkspaceActionsAvailableThroughSearch(panel);
await createTerminalFromCommandCenter(page, panel);
} finally {
await seeded.cleanup();
}
});
@@ -1,13 +1,13 @@
import { execFileSync } from "node:child_process";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { expectAppRoute } from "./helpers/route-assertions";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
const PRIMARY_HOST_LABEL = "Primary Host";
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "./fixtures";
import { test, expect } from "../support/fixtures";
const COMMIT_SUBJECT = "Show commit timestamps";
@@ -55,7 +55,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(panel.getByTestId(/^diff-file-0-group-\d+$/).first()).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
@@ -1,9 +1,10 @@
import { expect, test } from "./fixtures";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible } from "./helpers/composer";
import { expectAgentIdle } from "./helpers/agent-stream";
import { expect, test } from "../support/fixtures";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import {
openAttachmentMenu,
expectAttachmentSheetRowsOnTitleRail,
openGithubPickerFromMenu,
attachImageFromMenu,
expectAttachmentPill,
@@ -23,13 +24,19 @@ import {
selectGithubOption,
expectGithubAttachmentPill,
openGithubWorkspace,
} from "./helpers/composer";
import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { seedWorkspace } from "./helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id";
} from "../support/helpers/composer";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import { gotoAppShell } from "../support/helpers/app";
import {
waitForSidebarHydration,
switchWorkspaceViaSidebar,
} from "../support/helpers/workspace-ui";
import { seedWorkspace } from "../support/helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
@@ -57,6 +64,22 @@ test.describe("Composer attachments", () => {
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
});
test("compact Plus menu aligns attachment rows with its sheet title", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-sheet-rails-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await page.setViewportSize({ width: 390, height: 844 });
await openAttachmentMenu(page);
await expectAttachmentSheetRowsOnTitleRail(page);
});
test("GitHub combobox does not render until the picker is opened", async ({
page,
withWorkspace,
@@ -144,11 +167,6 @@ test.describe("Composer attachments", () => {
// needed before this can be exercised end-to-end.
});
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
});
test("image lightbox opens on pill click and closes on Escape", async ({
page,
withWorkspace,
@@ -1,14 +1,14 @@
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import { expect, test, type Page } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
} from "../support/helpers/mock-agent";
import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/archive-tab";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
const TEST_COMMANDS = [
{

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