Compare commits

..
Author SHA1 Message Date
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
453 changed files with 28613 additions and 3105 deletions
+80
View File
@@ -1,5 +1,85 @@
# Changelog
## 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
+2 -1
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">
+173
View File
@@ -0,0 +1,173 @@
<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-loop` — 명확한 완료 조건을 기준으로 에이전트를 반복 실행합니다(Ralph loop). 검증 에이전트를 추가할 수도 있습니다.
- `/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
+2 -1
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">
+2 -1
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">
+10 -1
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.
@@ -131,7 +140,7 @@ Provider descriptors may include one compact subtitle. The provider owns its con
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 is a subagent.** A backgrounded shell announces as `task_type: "local_bash"` with a `tool_use_id` and no `subagent_type`; workflows announce as `local_workflow`; ambient housekeeping sets `skip_transcript`. Filtering on the presence of a `tool_use_id` alone puts `sleep 20` in the subagents track.
- **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`.
+11
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
+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,
+1 -1
View File
@@ -696,7 +696,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
+7 -3
View File
@@ -221,11 +221,15 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
## 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: pushed away from the surface rather than toward it (darker in light, lighter in dark) and at 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 lightness the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. All four move together; regenerate the set, never one hue.
Identity badges — the project icon and the sidebar host badge — 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. Identity color is a fill-only contract: project icons use it as a fill with a white letter, while host badges use it on their icon and retain the normal muted text color. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
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>`.
+2
View File
@@ -29,6 +29,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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`).
- **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) or Loop (iterative re-execution of one 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`).
+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.
+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.
+37 -10
View File
@@ -26,6 +26,17 @@ 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`
@@ -86,16 +97,32 @@ and a column of icons there is decoration competing with the values you actually
## Surface details
- The hover fill is **inset by the same amount on every side and rounded** — a chip inside the
menu, not a band across it. The inset is taken _out of_ the row, never added to it: horizontally
padding gives up what margin takes so labels don't move, and vertically the fill is shorter by
the same amount so the row pitch is unchanged. Insetting by growing the row is how the menu ends
up taller than it started.
- The fill's height only holds because the label's `lineHeight` is pinned. Leave it to the platform
and content outgrows `minHeight`, which then does nothing.
- **The item's inset is the menu's only spacing.** Rows sit apart because each holds itself in, and
a separator gets clearance from its neighbours for free. Don't give the separator a margin of its
own — that is two numbers controlling one gap, and they will disagree.
- 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.
+124
View File
@@ -252,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
@@ -320,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
+2
View File
@@ -70,6 +70,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.
+13 -9
View File
@@ -36,7 +36,7 @@ Rules that apply to both steps:
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: 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. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never become the website's default download — they sit 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:
@@ -82,7 +82,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,7 +129,7 @@ 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.
@@ -238,9 +238,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
@@ -315,10 +317,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 rewrites the `-beta.N` entry in place, so that anchor dies while the group survives. 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
+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`
+1 -1
View File
@@ -1 +1 @@
sha256-WOVtBB7AQke7RM9FDJ1mUDU0sLMf03QOrvajrXSFwPs=
sha256-MDuVQQ5DmNoPlb9+LlrZYxGbP+22rYpfhcDJQxmIUsU=
+47 -47
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.5",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.5",
"version": "0.3.0",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -27721,16 +27721,6 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.2.0-beta.11",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.11.tgz",
"integrity": "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -29874,20 +29864,6 @@
"react-native": "*"
}
},
"node_modules/react-native-keyboard-controller": {
"version": "1.20.7",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.20.7.tgz",
"integrity": "sha512-G8S5jz1FufPrcL1vPtReATx+jJhT/j+sTqxMIb30b1z7cYEfMlkIzOCyaHgf6IMB2KA9uBmnA5M6ve2A9Ou4kw==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-markdown-display": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
@@ -35323,7 +35299,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.5",
"version": "0.3.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -35394,7 +35370,7 @@
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-keyboard-controller": "1.21.12",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.3.1",
@@ -36259,6 +36235,20 @@
"dev": true,
"license": "MIT"
},
"packages/app/node_modules/react-native-keyboard-controller": {
"version": "1.21.12",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.12.tgz",
"integrity": "sha512-pZJNXS//AhXDD9RvkNBZF5MsHv9qbFpNeHMIZ9ESSUqubNsACU7GyTE4GekIcJ1MBu+r9/TNZQuEfpT9v01HZg==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=3.0.0"
}
},
"packages/app/node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
@@ -36363,12 +36353,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.5",
"version": "0.3.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.5",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/server": "0.2.5",
"@getpaseo/client": "0.3.0",
"@getpaseo/protocol": "0.3.0",
"@getpaseo/server": "0.3.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36615,10 +36605,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.5",
"version": "0.3.0",
"dependencies": {
"@getpaseo/protocol": "0.2.5",
"@getpaseo/relay": "0.2.5",
"@getpaseo/protocol": "0.3.0",
"@getpaseo/relay": "0.3.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36629,7 +36619,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.5",
"version": "0.3.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36872,7 +36862,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.5",
"version": "0.3.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37768,7 +37758,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.5",
"version": "0.3.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38000,7 +37990,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.5",
"version": "0.3.0",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38013,7 +38003,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.5",
"version": "0.3.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38231,15 +38221,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.5",
"version": "0.3.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.5",
"@getpaseo/highlight": "0.2.5",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/relay": "0.2.5",
"@getpaseo/client": "0.3.0",
"@getpaseo/highlight": "0.3.0",
"@getpaseo/protocol": "0.3.0",
"@getpaseo/relay": "0.3.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38253,7 +38243,7 @@
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"node-pty": "1.2.0-beta.15",
"openai": "^6.44.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -38584,6 +38574,16 @@
"node": "20 || >=22"
}
},
"packages/server/node_modules/node-pty": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz",
"integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"packages/server/node_modules/openai": {
"version": "6.44.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz",
@@ -38778,7 +38778,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.5",
"version": "0.3.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.5",
"version": "0.3.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -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
+9 -28
View File
@@ -1,9 +1,12 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
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
? {
@@ -45,30 +48,6 @@ const buildProfile = isFdroidBuild
],
};
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) {
@@ -111,13 +90,13 @@ 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",
@@ -133,7 +112,7 @@ export default {
...(variant.googleServiceInfoPlist
? { googleServicesFile: variant.googleServiceInfoPlist }
: {}),
buildNumber: String(nativeBuildVersionCode),
buildNumber: nativeReleaseVersion.iosBuildNumber,
},
android: {
adaptiveIcon: {
@@ -147,7 +126,7 @@ export default {
usesCleartextTraffic: true,
permissions: buildProfile.androidPermissions,
package: variant.packageId,
versionCode: nativeBuildVersionCode,
versionCode: nativeReleaseVersion.androidVersionCode,
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
},
web: {
@@ -193,6 +172,7 @@ export default {
},
],
...buildProfile.fdroidPlugins,
...(isProfileBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -201,6 +181,7 @@ export default {
},
extra: {
fdroidBuild: isFdroidBuild,
profileBuild: isProfileBuild,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
@@ -915,39 +915,6 @@ test.describe("Agent message submission", () => {
}
});
test("reconciles an interrupted submission when live acknowledgements are missed", async ({
page,
}) => {
const gate = await installDaemonWebSocketGate(page);
const title = "Missed submission acknowledgements";
const prompt = "Interrupt without delivering live acknowledgements.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "submission-missed-acknowledgements-",
title,
model: "one-minute-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
gate.setAgentStreamItemSuppressed("user_message", true);
gate.setAgentStreamEventSuppressed("turn_canceled", true);
await submitMessage(page, prompt);
await gate.waitForAgentStreamItem("user_message");
await expectRunningAgentChrome(page, title);
await cancelAgent(page);
await gate.waitForAgentStreamEvent("turn_canceled");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
await expectAgentSurfacesIdle(page, title);
} finally {
gate.setAgentStreamItemSuppressed("user_message", false);
gate.setAgentStreamEventSuppressed("turn_canceled", false);
await agent.cleanup();
}
});
test("keeps layout stable when submitting to an agent with existing history", async ({
page,
submissionScenario,
@@ -1,4 +1,4 @@
import { test as base } from "../support/fixtures";
import { expect, test as base } from "../support/fixtures";
import {
appendSettledTimelineTurns,
createNearTenMegabyteAssistantPng,
@@ -45,6 +45,131 @@ test("switching between settled agent tabs keeps a real assistant PNG rendered",
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,
@@ -2,7 +2,22 @@ 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~~.",
@@ -36,6 +51,14 @@ const ASSISTANT_MARKDOWN = [
"",
"```typescript",
'const answer = "yes";',
" return answer;",
"```",
"",
"After code.",
"",
"```bash",
"echo trailing",
"",
"```",
"",
"````text",
@@ -50,12 +73,20 @@ const ASSISTANT_MARKDOWN = [
].join("\n");
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.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",
);
`${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;
@@ -66,12 +97,15 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
}
async function selectAssistantMessage(page: Page): Promise<void> {
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 expect(assistantMessage).toBeVisible();
await assistantMessage.evaluate((element) => {
await assistantMessage.locator(selector).evaluate((element) => {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(element);
@@ -84,6 +118,20 @@ async function selectAssistantText(page: Page, text: string): Promise<void> {
await selectAssistantTextRange(page, text, text);
}
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,
@@ -134,10 +182,66 @@ async function selectAssistantTextRange(
);
}
/**
* 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();
@@ -194,17 +298,19 @@ test("copying an assistant selection preserves Markdown structure and links", as
.filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveAttribute("data-pmono", "");
await selectAssistantMessage(page);
await selectStructuredClipboardFixture(page);
await copySelection(page);
const clipboard = await readRichClipboard(page);
expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN);
expect(clipboard.html).toContain("<ul>");
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('<ol start="5">');
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.',
@@ -234,64 +340,111 @@ test("copying an assistant selection preserves Markdown structure and links", as
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](https://example.com/issues/1)**");
expect(partialClipboard.html).toContain(
'<li><strong><a href="https://example.com/issues/1">First</a></strong></li>',
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 selectAssistantText(page, "docs");
await copySelection(page);
const titledLinkClipboard = await readRichClipboard(page);
expect(titledLinkClipboard.plainText).toBe('[docs](https://docs.example.com "Reference")');
expect(titledLinkClipboard.html).toContain(
'<a href="https://docs.example.com/" title="Reference">docs</a>',
);
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](https://autolink.example.com)",
);
expect(autolinkClipboard.html).toContain(
'<a href="https://autolink.example.com/">https://autolink.example.com</a>',
);
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](file:///tmp/paseo%20notes.md#L4)");
expect(fileLinkClipboard.html).toContain(
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
);
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("7. Seventh item\n8. Eighth item");
expect(midListClipboard.html).toContain('<ol start="7">');
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("3. 8. Inner eight\n 9. Inner nine");
expect(nestedListClipboard.html).toContain('<ol start="8">');
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("7. Seventh item\n8. Eighth item\n\nNested list:");
expect(crossBlockClipboard.html).toContain('<ol start="7">');
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);
@@ -309,6 +462,60 @@ test("copying an assistant selection preserves Markdown structure and links", as
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();
}
@@ -34,7 +34,10 @@ import {
type LongTimelineAgent,
} from "../support/helpers/timeline-pagination";
const WIDE_VIEWPORT = { width: 1280, height: 900 };
// 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", () => {
@@ -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 });
}
});
});
@@ -4,6 +4,7 @@ import { expectComposerVisible } from "../support/helpers/composer";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import {
openAttachmentMenu,
expectAttachmentSheetRowsOnTitleRail,
openGithubPickerFromMenu,
attachImageFromMenu,
expectAttachmentPill,
@@ -63,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,
@@ -0,0 +1,93 @@
import { test, expect } from "../support/fixtures";
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import {
expectNoCollapsedComposerToolbarFrame,
recordComposerToolbarFrames,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const SETTLE_MS = 1_000;
async function seedSettledMockAgent(workspace: SeededWorkspace, title: string) {
const agent = await workspace.client.createAgent({
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
});
await workspace.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle",
30_000,
);
return { id: agent.id, title, cwd: workspace.repoPath, workspaceId: workspace.workspaceId };
}
function visibleAgentTab(page: import("@playwright/test").Page, agentId: string) {
return page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first();
}
test.describe("Composer control density across tab switches", () => {
test.describe.configure({ timeout: 180_000 });
test("switching between agent tabs never paints a collapsed composer toolbar", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-density-agents-" });
try {
const first = await seedSettledMockAgent(workspace, "First chat");
const second = await seedSettledMockAgent(workspace, "Second chat");
await openWorkspaceWithAgents(page, [first, second]);
await recordComposerToolbarFrames(page);
await visibleAgentTab(page, first.id).click();
await page.waitForTimeout(SETTLE_MS);
await visibleAgentTab(page, second.id).click();
await page.waitForTimeout(SETTLE_MS);
await expectNoCollapsedComposerToolbarFrame(page);
} finally {
await workspace.cleanup();
}
});
test("switching between draft tabs never paints a collapsed composer toolbar", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-density-drafts-" });
try {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify({
provider: "mock",
providerPreferences: { mock: { mode: "load-test" } },
}),
);
});
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const draftTabs = page
.locator('[data-testid^="workspace-tab-draft"]')
.filter({ visible: true });
await expect(draftTabs).toHaveCount(2, { timeout: 30_000 });
await expect(
page.locator('[data-testid="mode-control"]').filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await recordComposerToolbarFrames(page);
await draftTabs.nth(0).click();
await page.waitForTimeout(SETTLE_MS);
await draftTabs.nth(1).click();
await page.waitForTimeout(SETTLE_MS);
await expectNoCollapsedComposerToolbarFrame(page);
} finally {
await workspace.cleanup();
}
});
});
@@ -108,13 +108,12 @@ test.describe("Host appearance", () => {
await leaveHostAppearanceSettings(page);
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
const badge = row.getByTestId(`host-badge-${twoHostSidebar.secondaryServerId}`);
await expect(badge).toHaveText(hostName);
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
expect(badgeBox).not.toBeNull();
expect(rowBox).not.toBeNull();
await expect(badge).toHaveCSS("flex-shrink", "1");
expect(badgeBox!.width).toBeGreaterThan(96);
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
@@ -4,6 +4,7 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled,
expectNewWorkspaceProjectSelected,
expectNewWorkspaceTriggerLabelsAligned,
openGlobalNewWorkspaceComposer,
openMissingProjectNewWorkspaceComposer,
openNewWorkspaceComposer,
@@ -18,7 +19,10 @@ import {
clickArchiveWorkspaceMenuItem,
expectWorkspaceAbsentFromSidebar,
} from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import {
switchWorkspaceViaSidebar,
waitForSidebarHydration,
} from "../support/helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The surviving entries are
// the global button (universal) and each project's per-row New workspace icon
@@ -66,6 +70,11 @@ test.describe("New workspace entry points", () => {
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
).toBeVisible({ timeout: 30_000 });
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: seeded.workspaceId,
});
const globalButton = page.getByTestId("sidebar-global-new-workspace");
await expect(globalButton).toBeVisible({ timeout: 30_000 });
@@ -77,6 +86,10 @@ test.describe("New workspace entry points", () => {
timeout: 30_000,
});
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 });
await expectNewWorkspaceTriggerLabelsAligned(page, {
projectLabel: seeded.projectDisplayName,
hostLabel: "localhost",
});
} finally {
await seeded.cleanup();
}
@@ -0,0 +1,86 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import {
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
attachButton,
expectChatComposerActive,
expectTerminalComposerActive,
fillTerminalPrompt,
seedTerminalProfiles,
selectChatLaunch,
selectLaunchOption,
terminalPromptInput,
type TerminalProfile,
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
const PROFILE: TerminalProfile = {
id: "e2e-composer-profile",
name: "Composer Profile",
command: "/bin/echo",
args: ["{{{prompt}}}"],
};
test.describe("New workspace: the composer is one control in two modes", () => {
let workspace: SeededWorkspace;
let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-composer-" });
profileSeed = await seedTerminalProfiles([PROFILE]);
});
test.afterEach(async () => {
await profileSeed.restore();
await workspace?.cleanup();
});
test("switching launch target does not leak draft text between chat and terminal, and drops the chat-only controls", async ({
page,
}) => {
test.setTimeout(60_000);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await test.step("a chat draft does not leak into the terminal prompt", async () => {
await fillNewWorkspaceDraft(page, "a chat-only draft");
await selectLaunchOption(page, PROFILE.id);
await expectTerminalComposerActive(page);
await expect(terminalPromptInput(page)).toHaveValue("");
});
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
await expect(attachButton(page)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
await expect(
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
name: "Launch",
}),
).toBeVisible();
});
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
await fillTerminalPrompt(page, "a terminal-only draft");
await selectChatLaunch(page);
await expectChatComposerActive(page);
await expectNewWorkspaceDraft(page, "a chat-only draft");
});
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
await selectLaunchOption(page, PROFILE.id);
await expectTerminalComposerActive(page);
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
});
});
});
@@ -0,0 +1,101 @@
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import {
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectChatLaunchSelected,
expectTerminalLaunchSelected,
expectTerminalOutputContains,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
patchTerminalProfiles,
seedTerminalProfiles,
selectChatLaunch,
selectLaunchOption,
submitTerminalLaunch,
type TerminalProfile,
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// `sleep` keeps the terminal alive long enough for the UI to attach and
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-memory-profile",
name: "Memory Profile",
command: "/bin/sh",
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
};
function hasLeftNewWorkspaceRoute(url: URL): boolean {
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
}
test.describe("New workspace: launch target memory", () => {
let workspace: SeededWorkspace;
let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-memory-" });
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE]);
});
test.afterEach(async () => {
await profileSeed.restore();
await workspace?.cleanup();
});
test("remembers the launch target across visits, and a removed profile falls back to chat instead of a dead entry", async ({
page,
}) => {
test.setTimeout(90_000);
await gotoAppShell(page);
await waitForSidebarHydration(page);
const openNewWorkspace = () =>
openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
await openNewWorkspace();
await selectLaunchOption(page, PROMPT_PROFILE.id);
await fillTerminalPrompt(page, "remember me");
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
await expectTerminalOutputContains(page, "remembered: remember me");
await openNewWorkspace();
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
});
await test.step("switching to Chat and submitting preselects Chat the next time", async () => {
await selectChatLaunch(page);
await expectChatLaunchSelected(page);
await submitNewWorkspacePrompt(page, "hello from chat");
// The chat handoff navigates asynchronously (draft -> created agent). Let
// it fully settle before moving on, so it can't fire a late redirect
// that races the next step's navigation.
await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 });
await openNewWorkspace();
await expectChatLaunchSelected(page);
});
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
await selectLaunchOption(page, PROMPT_PROFILE.id);
await fillTerminalPrompt(page, "second run");
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
await patchTerminalProfiles([]);
await openNewWorkspace();
await expectChatLaunchSelected(page);
});
});
});
@@ -0,0 +1,101 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalLaunchSelected,
expectTerminalOutputContains,
expectTerminalPreviewCommand,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
seedTerminalProfiles,
selectLaunchOption,
submitTerminalLaunch,
terminalLaunchSubmit,
terminalPromptInput,
type TerminalProfile,
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// Someone who wants a terminal agent instead of a chat: they pick a profile
// from the meta-row control, type a prompt, and the daemon actually spawns the
// resolved command with their prompt substituted in — no mocking Paseo's own
// code, the "profile" here is a real, harmless binary the daemon really runs.
//
// The sentinel becomes `$0` (a positional shell parameter, not string
// interpolation into the script source), so the prompt reaches the process
// exactly as typed with no injection risk. `sleep` keeps the terminal alive
// long enough for the UI to attach and render the output before it exits —
// a bare `echo` exits before the frontend subscribes to its output.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-echo-prompt",
name: "Echo Prompt",
command: "/bin/sh",
args: ["-c", 'echo captured: "$0"; sleep 10', "{{{prompt}}}"],
};
// No sentinel anywhere: takes no prompt, composer goes read-only.
const BARE_PROFILE: TerminalProfile = {
id: "e2e-echo-bare",
name: "Bare Echo",
command: "/bin/sh",
args: ["-c", "echo bare-launch-static-line; sleep 10"],
};
test.describe("New workspace: launching a terminal", () => {
let workspace: SeededWorkspace;
let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
});
test.afterEach(async () => {
await profileSeed.restore();
await workspace?.cleanup();
});
test("a profile that takes a prompt launches with it as its trailing argument; a profile without the sentinel launches bare from Launch", async ({
page,
}) => {
test.setTimeout(90_000);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await test.step("pick a profile that takes a prompt, type one, and submit", async () => {
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "fix the flaky test");
await submitTerminalLaunch(page);
});
await test.step("the workspace opens with a terminal tab, and the profile received the prompt", async () => {
await expectWorkspaceOpensWithTerminalTab(page);
await expectTerminalOutputContains(page, "captured: fix the flaky test");
});
await test.step("a profile without the sentinel is read-only and shows the resolved command", async () => {
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await selectLaunchOption(page, BARE_PROFILE.id);
await expect(terminalPromptInput(page)).toHaveCount(0);
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; sleep 10");
await expect(terminalLaunchSubmit(page)).toHaveText("Launch");
});
await test.step("Launch starts the profile bare, with no prompt to send", async () => {
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
await expectTerminalOutputContains(page, "bare-launch-static-line");
});
});
});
@@ -51,6 +51,7 @@ import {
type LocalGhPrFixture,
} from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { chooseAddProjectMethod, expectAddProjectPage } from "../support/helpers/add-project-flow";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
@@ -78,9 +79,7 @@ interface WorkspaceStatusGroupEvent {
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await selectSidebarStatusGrouping(page);
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
}
@@ -15,6 +15,8 @@ interface ProviderSubagentCase {
provider: RewindFlowProvider;
sentinel: string;
expectedName: string;
expectedSubtitle?: RegExp;
expectsUserMessage?: boolean;
prompt: string;
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
}
@@ -39,9 +41,12 @@ const cases: ProviderSubagentCase[] = [
{
provider: "opencode",
sentinel: "OPENCODE_CHILD_SENTINEL",
expectedName: "Explore",
expectedName: "Verify OpenCode descriptor",
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
expectsUserMessage: false,
providerConfig: { model: "openai/gpt-5.4" },
prompt:
"Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.",
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
},
];
@@ -51,7 +56,7 @@ test.describe("real provider subagent timelines", () => {
for (const scenario of cases) {
test(`${scenario.provider} exposes native child output from the subagent track`, async ({
page,
}) => {
}, testInfo) => {
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)),
);
@@ -71,13 +76,52 @@ test.describe("real provider subagent timelines", () => {
const rows = page.locator('[data-testid^="subagents-track-row-"]');
await expect(rows).toHaveCount(1, { timeout: 60_000 });
await expect(rows.first()).toContainText(scenario.expectedName);
if (scenario.expectedSubtitle) {
await expect(rows.first()).toContainText(scenario.expectedSubtitle);
const desktopTrackScreenshot = testInfo.outputPath(
`${scenario.provider}-subagent-track-desktop.png`,
);
await page.screenshot({ path: desktopTrackScreenshot });
await testInfo.attach(`${scenario.provider} subagent track desktop`, {
path: desktopTrackScreenshot,
contentType: "image/png",
});
}
await rows.first().click();
const panel = page.getByTestId("provider-subagent-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(
panel.getByTestId("user-message").filter({ hasText: scenario.sentinel }),
).toBeVisible({ timeout: 30_000 });
if (scenario.expectedSubtitle) {
await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText(
scenario.expectedSubtitle,
);
const desktopScreenshot = testInfo.outputPath(
`${scenario.provider}-subagent-desktop.png`,
);
await page.screenshot({ path: desktopScreenshot });
await testInfo.attach(`${scenario.provider} subagent desktop`, {
path: desktopScreenshot,
contentType: "image/png",
});
await page.setViewportSize({ width: 494, height: 862 });
await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText(
scenario.expectedSubtitle,
);
const compactScreenshot = testInfo.outputPath(
`${scenario.provider}-subagent-compact.png`,
);
await page.screenshot({ path: compactScreenshot });
await testInfo.attach(`${scenario.provider} subagent compact`, {
path: compactScreenshot,
contentType: "image/png",
});
await page.setViewportSize({ width: 1280, height: 720 });
}
if (scenario.expectsUserMessage !== false) {
await expect(
panel.getByTestId("user-message").filter({ hasText: scenario.sentinel }),
).toBeVisible({ timeout: 30_000 });
}
await expect(
panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }),
).toBeVisible({ timeout: 30_000 });
@@ -0,0 +1,71 @@
import { test } from "@playwright/test";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { expectRunningAgentChrome } from "../support/helpers/agent-stream";
import { startLocalElixirRelay } from "../support/helpers/local-elixir-relay";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
startPackagedWebDaemon,
type PackagedWebDaemon,
} from "../support/helpers/packaged-web-daemon";
import {
connectDaemonWebAppOnlyThroughRelay,
measureRelayRestartDuringStream,
} from "../support/helpers/relay-deployment";
test("a streaming chat recovers from a relay deployment without user action", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const relay = await startLocalElixirRelay();
let daemon: PackagedWebDaemon | null = null;
let cleanupChat = async () => {};
try {
daemon = await startPackagedWebDaemon({ relayEndpoint: relay.endpoint });
const runningDaemon = daemon;
await test.step("Connect the daemon-served app only through the relay", async () => {
await connectDaemonWebAppOnlyThroughRelay(page, runningDaemon);
});
await test.step("Open a running mock-provider chat", async () => {
const chat = await seedMockAgentWorkspace({
repoPrefix: "relay-deployment-reconnect-",
title: "Relay deployment stream",
port: runningDaemon.port,
model: "five-minute-stream",
initialPrompt: "Stream while the relay is deployed.",
});
cleanupChat = chat.cleanup;
const route = buildHostAgentDetailRoute(
runningDaemon.serverId,
chat.agentId,
chat.workspaceId,
);
await page.goto(new URL(route, runningDaemon.origin).toString());
await expectRunningAgentChrome(page, "Relay deployment stream");
});
await test.step("Restart the relay and measure the visible interruption", async () => {
const measurements = await measureRelayRestartDuringStream({
page,
relay,
agentTitle: "Relay deployment stream",
});
await testInfo.attach("relay-deployment-measurements", {
body: JSON.stringify(measurements, null, 2),
contentType: "application/json",
});
console.log(`Relay deployment measurements: ${JSON.stringify(measurements)}`);
});
} finally {
try {
await cleanupChat();
} finally {
try {
await daemon?.close();
} finally {
await relay.close();
}
}
}
});
@@ -140,7 +140,8 @@ test.describe("Schedules", () => {
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toHaveCount(0);
await expect(thinkingTrigger).toContainText("Low");
await expectSettled(thinkingTrigger);
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
await expectSettled(modeTrigger);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
@@ -217,7 +217,8 @@ test.describe("Schedules project target", () => {
const modelTrigger = page.getByTestId("schedule-model-trigger");
await expect(modelTrigger).toContainText("Ten second stream");
await expectSettled(modelTrigger);
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-thinking-trigger")).toContainText("Low");
await expectSettled(page.getByTestId("schedule-thinking-trigger"));
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
await expectSettled(page.getByTestId("schedule-mode-trigger"));
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
@@ -327,7 +328,8 @@ test.describe("Schedules project target", () => {
await selectModelByLabel(page, "Ten second stream");
await expect(modelTrigger).toContainText("Ten second stream");
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toHaveCount(0);
await expect(thinkingTrigger).toContainText("Low");
await expectSettled(thinkingTrigger);
await expect(modeTrigger).toBeVisible();
await expectSettled(modeTrigger);
@@ -0,0 +1,148 @@
import { randomUUID } from "node:crypto";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openSessions } from "../support/helpers/archive-tab";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { addConnectedHostAndReload, addOfflineHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { connectSeedClient, seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
// The constants module is plain TypeScript, so the Playwright runner can import
// it — reaching through the `.tsx` picker would drag React Native into Node.
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker-constants";
const AGENT_ROW = '[data-testid^="agent-row-"]';
const PRIMARY_LABEL = "Primary box";
const SECONDARY_LABEL = "Secondary box";
/** Scopes every assertion to this spec's sessions on a shared daemon. */
const NONCE = `hsx${randomUUID().replaceAll("-", "").slice(0, 8)}`;
async function search(page: Page, query: string): Promise<void> {
await page.getByTestId("sessions-search-input").fill(query);
}
async function selectHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId("sessions-host-filter-trigger").click();
await page.getByTestId(`sessions-host-filter-item-${serverId}`).click();
}
async function expectRankedTitles(page: Page, titles: string[]): Promise<void> {
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
for (const [index, title] of titles.entries()) {
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
}
}
// Searching across hosts is the case a single-daemon spec cannot see: each host
// ranks only its own sessions, so the merge is the only thing that can put the
// best match first.
test.describe("History search across hosts", () => {
test.describe.configure({ timeout: 420_000 });
test("ranks matches from every host together and narrows to one host on demand", async ({
page,
}) => {
const secondaryServerId = `srv_hist_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const secondaryDaemon = await startIsolatedHostDaemon(secondaryServerId);
const primaryClient = await connectSeedClient();
let primaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
let secondaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
// The weak match is the newest session on the primary host and the strong
// match is the oldest on the secondary, so only a real cross-host merge on
// relevance can order them correctly.
const weakTitle = `${NONCE} Unbilled usage report`;
const strongTitle = `${NONCE} Bill the customer`;
const unrelatedTitle = `${NONCE} Terminal resize fix`;
try {
secondaryWorkspace = await seedWorkspace({
repoPrefix: "sessions-search-secondary-",
port: secondaryDaemon.port,
});
await createIdleAgent(secondaryWorkspace.client, {
cwd: secondaryWorkspace.repoPath,
workspaceId: secondaryWorkspace.workspaceId,
title: strongTitle,
});
primaryWorkspace = await seedWorkspace({ repoPrefix: "sessions-search-primary-" });
for (const title of [unrelatedTitle, weakTitle]) {
await createIdleAgent(primaryClient, {
cwd: primaryWorkspace.repoPath,
workspaceId: primaryWorkspace.workspaceId,
title,
});
}
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryDaemon.serverId,
label: SECONDARY_LABEL,
port: secondaryDaemon.port,
primaryLabel: PRIMARY_LABEL,
});
await openSessions(page);
// All hosts: the stronger match leads despite being the older session on
// the other daemon.
await search(page, `${NONCE} bill`);
await expectRankedTitles(page, [strongTitle, weakTitle]);
// One host: the other daemon's stronger match drops out entirely.
await selectHost(page, getServerId());
await expectRankedTitles(page, [weakTitle]);
await selectHost(page, secondaryDaemon.serverId);
await expectRankedTitles(page, [strongTitle]);
// Back to all hosts, and clearing the query returns every seeded session.
await selectHost(page, ALL_HOSTS_OPTION_ID);
await page.getByTestId("sessions-search-clear").click();
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
await expect(rows).toHaveCount(3, { timeout: 30_000 });
} finally {
await primaryWorkspace?.cleanup().catch(() => undefined);
await secondaryWorkspace?.cleanup().catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
}
});
test("names a host it could not reach instead of shrinking the results silently", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sessions-search-offline-" });
const reachableTitle = `${NONCE} Bill the reachable customer`;
try {
await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: reachableTitle,
});
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: "sessions-search-unreachable",
label: SECONDARY_LABEL,
primaryLabel: PRIMARY_LABEL,
});
await openSessions(page);
// The unreachable host's sessions were never searched. Reporting only the
// matches that were found, with no word about the host that never
// answered, is the failure this asserts against.
await search(page, `${NONCE} bill`);
await expect(page.getByTestId("sessions-host-errors")).toContainText(SECONDARY_LABEL, {
timeout: 30_000,
});
await expectRankedTitles(page, [reachableTitle]);
} finally {
await workspace.cleanup().catch(() => undefined);
}
});
});
@@ -0,0 +1,142 @@
import { randomUUID } from "node:crypto";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
createIdleAgent,
openSessions,
resetSeededPageState,
} from "../support/helpers/archive-tab";
const AGENT_ROW = '[data-testid^="agent-row-"]';
/**
* Every seeded title opens with the same nonce, so a query of "<nonce> term"
* can only reach this spec's sessions. The daemon is shared with the rest of
* the browser suite and its history is whatever those specs left behind.
*/
const NONCE = `hsq${randomUUID().replaceAll("-", "").slice(0, 8)}`;
const TITLES = {
billing: `${NONCE} Add Stripe billing`,
unbilled: `${NONCE} Unbilled usage report`,
terminal: `${NONCE} Terminal resize fix`,
} as const;
async function search(page: Page, query: string): Promise<void> {
await page.getByTestId("sessions-search-input").fill(query);
}
function rowTitles(page: Page) {
return page.locator(AGENT_ROW);
}
async function expectVisibleTitles(page: Page, titles: string[]): Promise<void> {
const rows = rowTitles(page).filter({ hasText: NONCE });
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
for (const [index, title] of titles.entries()) {
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
}
}
test.describe("History search", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let projectId: string;
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("sessions-search-");
client = await connectSeedClient();
const created = await client.createWorkspace({
source: { kind: "directory", path: tempRepo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
}
projectId = created.workspace.projectId;
const workspaceId = created.workspace.id;
for (const title of [TITLES.terminal, TITLES.unbilled, TITLES.billing]) {
await createIdleAgent(client, { cwd: tempRepo.path, workspaceId, title });
}
});
test.afterAll(async () => {
await client?.removeProject(projectId).catch(() => undefined);
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
test("typing narrows history to matching sessions and clearing restores them", async ({
page,
}) => {
await resetSeededPageState(page);
await openSessions(page);
// Seeded newest-first, and at rest history is chronological.
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
await search(page, `${NONCE} billing`);
await expectVisibleTitles(page, [TITLES.billing]);
// Ranked results are one flat list — a day heading would claim an order
// the list no longer has.
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("sessions-search-clear").click();
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
});
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
// "bill" starts a word in "billing" and hides inside "unbilled", so the
// stronger match leads even though both sessions are equally recent.
await search(page, `${NONCE} bill`);
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
});
test("marks the characters each result matched on", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
// The mark is a nested Text run, so the matched slice is its own element.
await search(page, `${NONCE} billing`);
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
// A typo has no characters in the text to point at, so the whole word it
// resolved to is marked.
await search(page, `${NONCE} bulling`);
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("finds a session through a typo", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} bulling`);
await expectVisibleTitles(page, [TITLES.billing]);
});
test("says the query found nothing, not that history is empty", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} kubernetes`);
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
await page.getByText("Clear search").click();
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
});
});
@@ -0,0 +1,52 @@
import { test } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import { getE2EDaemonPort, wsRoutePatternForPort } from "../support/helpers/daemon-port";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
addDirectHostFromSettings,
clickSettingsBackToWorkspace,
openSettingsHostSection,
} from "../support/helpers/settings";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
test.describe("Settings host selection", () => {
test.describe.configure({ timeout: 180_000 });
test("entering Settings from a remote workspace selects that remote host", async ({ page }) => {
const remoteDaemon = await startIsolatedHostDaemon("settings-host-selection-remote");
const remoteWorkspace = await seedWorkspace({
port: remoteDaemon.port,
repoPrefix: "settings-host-selection-remote-workspace-",
title: "Remote workspace",
});
try {
// The default local profile remains in the registry, but its daemon is offline.
await page.routeWebSocket(wsRoutePatternForPort(getE2EDaemonPort()), async (ws) => {
await ws.close({ code: 1008, reason: "The local daemon is disconnected." });
});
await page.goto("/");
await openSettings(page);
await addDirectHostFromSettings(page, {
host: "127.0.0.1",
port: remoteDaemon.port,
});
await clickSettingsBackToWorkspace(page);
await switchWorkspaceViaSidebar({
page,
serverId: remoteDaemon.serverId,
workspaceId: remoteWorkspace.workspaceId,
});
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
} finally {
await remoteWorkspace.cleanup().catch(() => undefined);
await remoteDaemon.close().catch(() => undefined);
}
});
});
@@ -21,3 +21,21 @@ test("Settings language selector switches General labels", async ({ page }) => {
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
});
test("Settings language selector switches to Korean", async ({ page }) => {
test.setTimeout(120_000);
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "general");
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("button", { name: "한국어 - Korean", exact: true }).click();
await expect(page.getByText("기본 전송", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "한국어", exact: true }).click();
await page.getByRole("button", { name: "English - 영어", exact: true }).click();
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
});
@@ -114,6 +114,16 @@ test.describe("Settings sidebar navigation", () => {
await expectDirectHostUriHidden(page);
});
test("browser web hides direct connection custom headers", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await toggleHostAdvanced(page);
await expect(page.getByTestId("direct-header-add")).toHaveCount(0);
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -7,7 +7,9 @@ const DISCORD_DESTINATION =
const GITHUB_ISSUE_DESTINATION =
/^https:\/\/github\.com\/(?:getpaseo\/paseo\/issues\/new(?:\/choose)?(?:[/?#]|$)|login\?return_to=https%3A%2F%2Fgithub\.com%2Fgetpaseo%2Fpaseo%2Fissues%2Fnew$)/;
const CHANGELOG_DESTINATION = /^https:\/\/paseo\.sh\/changelog(?:[/?#]|$)/;
const APP_VERSION = /^Paseo v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
// The name and the version are separate cells of a key/value row, so they meet with no space
// between them in the row's text content.
const APP_VERSION = /^Paseo\s*v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
async function openHelpMenu(page: Page): Promise<void> {
await page.getByTestId("sidebar-help").click();
@@ -5,6 +5,7 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -132,9 +133,7 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
// Switch to status grouping.
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await selectSidebarStatusGrouping(page);
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
@@ -3,6 +3,7 @@ import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
// The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing
// whenever the row was unmounted — a collapsed project section being the common case. It now
@@ -39,9 +40,7 @@ async function collapseProjectSection(page: Page, project: SeededWorkspace): Pro
}
async function switchToStatusGrouping(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await selectSidebarStatusGrouping(page);
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
}
@@ -6,6 +6,7 @@ import {
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
} from "../support/helpers/sidebar";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
@@ -227,18 +228,19 @@ test.describe("Mobile sidebar panelState transition", () => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
try {
// Pinning behavior has its own E2E suite. Seed this test's precondition directly so it only
// exercises whether the retained compact sidebar keeps the pinned row mounted while closed.
await workspace.client.setWorkspacePinned(workspace.workspaceId, true);
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))).toHaveCount(1);
await expect(row).toHaveCount(1);
} finally {
await workspace.cleanup();
}
@@ -1,5 +1,6 @@
import { test, expect, type Page } from "../support/fixtures";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { TerminalE2EHarness, withTerminalInApp } from "../support/helpers/terminal-dsl";
import { getTerminalBufferText } from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { getServerId } from "../support/helpers/server-id";
@@ -97,6 +98,15 @@ async function captureTerminalText(
return capture.lines.join("\n");
}
async function waitForTerminalMarker(page: Page, marker: string): Promise<void> {
await expect
.poll(() => getTerminalBufferText(page), {
message: "terminal stream should deliver PTY output after focus returns",
timeout: 10_000,
})
.toContain(marker);
}
test.describe("terminal PTY size claim under lost window focus", () => {
let harness: TerminalE2EHarness;
@@ -174,4 +184,22 @@ test.describe("terminal PTY size claim under lost window focus", () => {
)
.toEqual({ rows: rendered.rows, cols: rendered.cols });
});
test("terminal output remains subscribed after window focus returns", async ({ page }) => {
await withTerminalInApp(page, harness, { name: "focus-return-output" }, async (terminal) => {
const marker = `OUTPUT_AFTER_FOCUS_${Date.now()}`;
await setWindowFocused(page, false);
await page.waitForTimeout(50);
await setWindowFocused(page, true);
await page.waitForTimeout(50);
harness.client.sendTerminalInput(terminal.id, {
type: "input",
data: `printf "\\n${marker}\\n"\n`,
});
await waitForTerminalMarker(page, marker);
});
});
});
@@ -12,6 +12,7 @@ import {
expectTurnCopyButton,
} from "../support/helpers/agent-stream";
import {
expectReconnectingToastDebounced,
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
@@ -179,6 +180,7 @@ test.describe("Viewed agent timelines", () => {
"true",
);
await gate.drop();
await expectReconnectingToastDebounced(page);
await expectReconnectingToastVisible(page);
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
await expect(
@@ -196,4 +198,23 @@ test.describe("Viewed agent timelines", () => {
await scenario.cleanup();
}
});
test("preserves reconnecting toast through retained tab switches", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await selectAgent(page, "First viewed chat");
await gate.drop();
await expectReconnectingToastVisible(page);
await selectAgent(page, "Second viewed chat");
await expectReconnectingToastVisible(page, { timeout: 500 });
} finally {
gate.restore();
await scenario.cleanup();
}
});
});
@@ -0,0 +1,190 @@
import { expect, test, type Page } from "../support/fixtures";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
chooseCommandCenterAgentControl,
submitDraftAgent,
waitForDraftComposer,
} from "../support/helpers/command-center-agent-controls";
import { openCommandCenter } from "../support/helpers/command-center";
import { selectModel } from "../support/helpers/app";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
async function openNewAgentTab(page: Page): Promise<void> {
await clickNewChat(page);
await waitForDraftComposer(page);
}
async function chooseDraftControl(page: Page, query: string, choice: string): Promise<void> {
await openCommandCenter(page);
await chooseCommandCenterAgentControl({ page, query, choice });
}
async function expectThinkingSelected(page: Page, label: string): Promise<void> {
await expect(
page
.getByRole("button", { name: `Select thinking option (${label})` })
.filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
}
async function expectModeSelected(page: Page, label: string): Promise<void> {
await expect(
page.getByRole("button", { name: `Select agent mode (${label})` }).filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
}
async function reloadWithPersistedPreferences(page: Page): Promise<void> {
await page.evaluate(
({ disableKey, nonceKey }) => {
const nonce = localStorage.getItem(nonceKey);
if (!nonce) throw new Error("Expected the Playwright seed nonce before reload");
localStorage.setItem(disableKey, nonce);
},
{ disableKey: DISABLE_DEFAULT_SEED_ONCE_KEY, nonceKey: SEED_NONCE_KEY },
);
await page.reload();
await waitForDraftComposer(page);
}
async function readRememberedThinking(page: Page, modelId: string): Promise<string | null> {
return page.evaluate((selectedModelId) => {
const raw = localStorage.getItem("@paseo:create-agent-preferences");
if (!raw) return null;
const preferences = JSON.parse(raw) as {
providerPreferences?: { mock?: { thinkingByModel?: Record<string, string> } };
};
return preferences.providerPreferences?.mock?.thinkingByModel?.[selectedModelId] ?? null;
}, modelId);
}
async function seedLegacyModelPreference(page: Page): Promise<void> {
await page.evaluate(() => {
const storageKey = "@paseo:create-agent-preferences";
const raw = localStorage.getItem(storageKey);
const preferences = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
localStorage.setItem(
storageKey,
JSON.stringify({
...preferences,
provider: "mock",
providerPreferences: {
...(preferences.providerPreferences as Record<string, unknown> | undefined),
mock: {
model: "legacy-five-minute-stream",
thinkingByModel: { "legacy-five-minute-stream": "medium" },
},
},
}),
);
});
}
test.describe("Workspace draft thinking preferences", () => {
test.describe.configure({ timeout: 180_000 });
test("restores a model's remembered thinking after another model and a reload", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "draft-thinking-preferences-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await openNewAgentTab(page);
await chooseDraftControl(
page,
"five minute stream",
"Model › Mock Load Test › Five minute stream",
);
await chooseDraftControl(page, "medium", "Thinking › Medium");
await expectThinkingSelected(page, "Medium");
await chooseDraftControl(
page,
"ten second stream",
"Model › Mock Load Test › Ten second stream",
);
await chooseDraftControl(
page,
"five minute stream",
"Model › Mock Load Test › Five minute stream",
);
await expectThinkingSelected(page, "Medium");
await submitDraftAgent(page, "Remember medium thinking for this model");
await openNewAgentTab(page);
await expectThinkingSelected(page, "Medium");
await chooseDraftControl(
page,
"ten second stream",
"Model › Mock Load Test › Ten second stream",
);
await submitDraftAgent(page, "Hello from the other model");
await openNewAgentTab(page);
await reloadWithPersistedPreferences(page);
await expect.poll(() => readRememberedThinking(page, "five-minute-stream")).toBe("medium");
await chooseDraftControl(
page,
"five minute stream",
"Model › Mock Load Test › Five minute stream",
);
await expectThinkingSelected(page, "Medium");
} finally {
await workspace.cleanup();
}
});
test("restores a provider's permission mode after another provider and a reload", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "draft-mode-preferences-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await openNewAgentTab(page);
await chooseDraftControl(
page,
"five minute stream",
"Model › Mock Load Test › Five minute stream",
);
await chooseDraftControl(page, "approval test", "Mode › Approval test");
await expectModeSelected(page, "Approval test");
await submitDraftAgent(page, "Remember this permission mode");
await openNewAgentTab(page);
await expectModeSelected(page, "Approval test");
await selectModel(page, "gpt-5.4-mini");
await openNewAgentTab(page);
await reloadWithPersistedPreferences(page);
await selectModel(page, "Five minute stream");
await expectModeSelected(page, "Approval test");
} finally {
await workspace.cleanup();
}
});
test("canonicalizes a retired model preference and restores its thinking option", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "draft-model-alias-preferences-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await openNewAgentTab(page);
await seedLegacyModelPreference(page);
await reloadWithPersistedPreferences(page);
await expect(
page.getByRole("button", { name: "Select model (Five minute stream)" }),
).toBeVisible({ timeout: 30_000 });
await expectThinkingSelected(page, "Medium");
} finally {
await workspace.cleanup();
}
});
});
@@ -16,6 +16,7 @@ import {
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectSubagentRowVisible,
@@ -82,9 +83,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await selectSidebarStatusGrouping(page);
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
timeout: 30_000,
});
@@ -21,6 +21,7 @@ import {
selectNewWorkspaceProject,
submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -397,9 +398,7 @@ async function expectWorkspaceRowInStatusBucket(
page: Page,
input: { serverId: string; workspaceId: string; bucket: string },
) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await selectSidebarStatusGrouping(page);
await expect(
page
.getByTestId(`sidebar-status-group-rows-${input.bucket}`)
@@ -30,6 +30,7 @@ import {
expectReconnectingToastGone,
switchWorkspaceViaSidebar,
waitForSidebarHydration,
waitForWorkspaceInSidebar,
workspaceDeckEntryLocator,
expectWorkspaceDeckEntryCount,
} from "../support/helpers/workspace-ui";
@@ -37,7 +38,8 @@ import { clickSettingsBackToWorkspace } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { addConnectedHostAndReload, addOfflineHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
async function expectNoLoadingWorkspacePane(
@@ -199,6 +201,62 @@ test.describe("Workspace navigation regression", () => {
}
});
test("does not show reconnecting for an inactive host workspace", async ({ page }) => {
const secondaryHost = await startIsolatedHostDaemon("inactive-reconnecting-host");
const primaryWorkspace = await seedWorkspace({ repoPrefix: "active-reconnecting-host-" });
const secondaryWorkspace = await seedWorkspace({
repoPrefix: "inactive-reconnecting-host-",
port: secondaryHost.port,
});
try {
await Promise.all([
createIdleAgent(primaryWorkspace.client, {
cwd: primaryWorkspace.repoPath,
workspaceId: primaryWorkspace.workspaceId,
title: "Active host agent",
}),
createIdleAgent(secondaryWorkspace.client, {
cwd: secondaryWorkspace.repoPath,
workspaceId: secondaryWorkspace.workspaceId,
title: "Inactive host agent",
}),
]);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: "Inactive host",
port: secondaryHost.port,
});
await waitForWorkspaceInSidebar(page, {
serverId: secondaryHost.serverId,
workspaceId: secondaryWorkspace.workspaceId,
});
await switchWorkspaceViaSidebar({
page,
serverId: secondaryHost.serverId,
workspaceId: secondaryWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: primaryWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await secondaryHost.close();
await page.waitForTimeout(1_500);
await expectReconnectingToastGone(page, { timeout: 100 });
} finally {
await secondaryHost.close();
await secondaryWorkspace.cleanup();
await primaryWorkspace.cleanup();
}
});
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
page,
}) => {
@@ -0,0 +1,32 @@
export const meta = {
name: "paseo-project-inspection",
description: "Inspect Paseo and explain what the project does",
whenToUse: "Paseo real-provider workflow detail QA",
phases: [{ title: "Inspect", detail: "Read the product and architecture sources" }],
};
phase("Inspect");
let input = args;
if (typeof input === "string") input = JSON.parse(input);
const repoPath = input?.repoPath;
if (typeof repoPath !== "string" || repoPath.length === 0) {
throw new Error("repoPath is required");
}
const report = await agent(
`Inspect the Paseo repository at ${JSON.stringify(repoPath)}.
Read README.md, docs/product.md, docs/architecture.md, and package.json. Do not edit anything. Return a concise plain-text report with exactly these headings:
What Paseo is
Who it is for
How it works
Repository structure
Under each heading, give 1-3 concrete sentences grounded in those files. Mention the supported coding agents and the local-code/privacy model.`,
{
label: "project-inspector",
phase: "Inspect",
},
);
return { report };
@@ -0,0 +1,30 @@
export const meta = {
name: "paseo-workflow-row-qa",
description: "Verify the workflow row lifecycle",
whenToUse: "Paseo real-provider QA only",
phases: [{ title: "Verify", detail: "one child returns a fixed marker" }],
};
phase("Verify");
let input = args;
if (typeof input === "string") input = JSON.parse(input);
const gatePath = input?.gatePath;
if (typeof gatePath !== "string" || gatePath.length === 0) {
throw new Error("gatePath is required");
}
const child = await agent(
`First use Bash to run this command and wait for it to exit:
while [ ! -f ${JSON.stringify(gatePath)} ]; do sleep 0.2; done
Then return exactly this JSON object and do nothing else: {"marker":"PASEO_WORKFLOW_ROW_OK"}`,
{
label: "workflow-row-child",
phase: "Verify",
schema: {
type: "object",
required: ["marker"],
properties: { marker: { type: "string" } },
},
},
);
return { child };
@@ -102,6 +102,8 @@ export async function waitForScrollableChat(
.toBeGreaterThan(input.minScrollableDistance);
}
const WHEEL_SETTLE_TIMEOUT_MS = 5_000;
export async function scrollChatAwayFromBottom(
page: Page,
input: { deltaY: number; minDistanceFromBottom: number },
@@ -111,31 +113,41 @@ export async function scrollChatAwayFromBottom(
if (!box) {
throw new Error("Agent chat scroll container is not visible");
}
const wheelSettled = scroll.evaluate(
(root: Element) =>
new Promise<void>((resolve) => {
root.addEventListener(
"wheel",
() => {
const scrollElement = root as HTMLElement;
let previousOffset = scrollElement.scrollTop;
let stableFrames = 0;
const sample = () => {
const currentOffset = scrollElement.scrollTop;
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
previousOffset = currentOffset;
if (stableFrames >= 3) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
},
{ once: true },
);
}),
);
// Letting the scroll animation stop before measuring is an optimization, so it must never be
// the thing that decides the test — the assertion below is. The old wait could not say that:
// it resolved only from a `requestAnimationFrame` sampler started by a `wheel` listener, and
// rAF does not fire at all while the page is occluded, which is reachable on a CI runner and
// stalls the sampler on its first frame. A missed wheel event did the same. Either way the
// test hung until its own timeout with nothing to read. A timed sampler with a deadline
// cannot: worst case it gives up and the poll below reports what the scroll actually did.
const wheelSettled = scroll.evaluate((root: Element, timeoutMs: number) => {
const scrollElement = root as HTMLElement;
return new Promise<void>((resolve) => {
const startedAt = Date.now();
let sawWheel = false;
let previousOffset = scrollElement.scrollTop;
let stableSamples = 0;
root.addEventListener(
"wheel",
() => {
sawWheel = true;
},
{ once: true },
);
const sample = () => {
const currentOffset = scrollElement.scrollTop;
const isStable = sawWheel && Math.abs(currentOffset - previousOffset) <= 0.5;
stableSamples = isStable ? stableSamples + 1 : 0;
previousOffset = currentOffset;
if (stableSamples >= 3 || Date.now() - startedAt >= timeoutMs) {
resolve();
return;
}
setTimeout(sample, 16);
};
sample();
});
}, WHEEL_SETTLE_TIMEOUT_MS);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, input.deltaY);
await wheelSettled;
@@ -77,6 +77,9 @@ export async function expectNoChatOutlinePreview(page: Page): Promise<void> {
export async function expectNoChatOutlinePreviewWhileCrossingToSidebar(page: Page): Promise<void> {
const railBox = await requireBoundingBox(chatOutlineRail(page));
const clockStart = Date.now();
await page.clock.install({ time: clockStart });
await page.clock.pauseAt(clockStart + 60_000);
await page.mouse.move(railBox.x + railBox.width + 2, railBox.y + railBox.height / 2);
await page.evaluate(() => {
document.body.dataset.chatOutlinePreviewObserved = String(
@@ -91,19 +94,19 @@ export async function expectNoChatOutlinePreviewWhileCrossingToSidebar(page: Pag
Object.assign(window, { __chatOutlinePreviewObserver: observer });
});
for (let step = 0; step <= 10; step += 1) {
// Keep the transit slower than the activation delay at every point while making the whole
// crossing longer than it. Horizontal motion must keep postponing activation until leave
// cancels the final pending timer.
const transitSteps = 6;
for (let step = 0; step < transitSteps; step += 1) {
await page.mouse.move(
railBox.x + railBox.width - 1 - (step * railBox.width) / 10,
railBox.x + railBox.width - 1 - (step * railBox.width) / transitSteps,
railBox.y + railBox.height / 2,
);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
await page.clock.runFor(100);
}
await page.mouse.move(railBox.x - 2, railBox.y + railBox.height / 2);
await page.clock.runFor(200);
const previewAppeared = await page.evaluate(() => {
const observer = Reflect.get(window, "__chatOutlinePreviewObserver") as
@@ -0,0 +1,75 @@
import { writeFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { submitMessage } from "./composer";
import type { AgentHandle } from "./rewind-flow";
import { openSubagentsTrack } from "./subagents";
export const WORKFLOW_ROW_DESCRIPTION = "Verify the workflow row lifecycle";
export const WORKFLOW_ROW_MARKER = "PASEO_WORKFLOW_ROW_OK";
export async function askClaudeToRunWorkflow(
handle: AgentHandle,
scriptPath: string,
gatePath: string,
): Promise<void> {
await submitMessage(
handle.page,
`Use Claude Code's Workflow tool exactly once with scriptPath ${JSON.stringify(
scriptPath,
)} and args ${JSON.stringify({ gatePath })}. Wait for its completion notification, then reply with exactly ${WORKFLOW_ROW_MARKER}.`,
);
await expect(handle.page.getByTestId("subagents-track-header")).toBeVisible({ timeout: 60_000 });
await openSubagentsTrack(handle.page);
}
export function releaseWorkflow(gatePath: string): void {
writeFileSync(gatePath, "release\n", "utf8");
}
export function workflowRow(page: Page) {
// The parent transcript's Workflow tool card has the same accessible name. The track has no
// separate landmark, so its established row test id is the only unambiguous scope.
return page
.getByTestId(/^subagents-track-row-/)
.filter({ has: page.getByText(WORKFLOW_ROW_DESCRIPTION, { exact: true }) });
}
export async function expectWorkflowRunning(page: Page): Promise<void> {
const row = workflowRow(page);
await expect(row).toBeVisible({ timeout: 60_000 });
await expect(row).toContainText(WORKFLOW_ROW_DESCRIPTION);
await expect(row).toContainText(/Workflow(?: · .+)?/);
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
}
export async function expectWorkflowCompleted(page: Page): Promise<void> {
const row = workflowRow(page);
await expect(row.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0, {
timeout: 120_000,
});
await expect(page.getByTestId("subagents-track-archive-finished")).toBeVisible();
await expect(
page.getByTestId("assistant-message").filter({ hasText: WORKFLOW_ROW_MARKER }).last(),
).toBeVisible({ timeout: 120_000 });
}
export async function expectSingleWorkflowParentCard(page: Page): Promise<void> {
const parentToolCards = page.getByTestId("tool-call-badge");
await expect(
parentToolCards.filter({ has: page.getByText("Workflow", { exact: true }) }),
).toHaveCount(1);
await expect(parentToolCards.filter({ hasText: "Task Notification" })).toHaveCount(0);
}
export async function openWorkflowTimeline(page: Page): Promise<void> {
await workflowRow(page).click();
const panel = page.getByTestId("provider-subagent-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText(
/Workflow(?: · .+)?/,
);
await expect(panel.getByText("Start chatting with this agent...", { exact: true })).toHaveCount(
0,
);
await expect(panel.getByText(WORKFLOW_ROW_MARKER, { exact: true })).toBeVisible();
}
@@ -0,0 +1,55 @@
import { expect, type Page } from "@playwright/test";
/**
* The composer toolbar collapses its controls by measured width. A collapsed
* frame is observable from the DOM: the mode trigger drops its label and
* shrinks to the icon-only box, so a visible trigger with no text is a
* collapsed paint.
*/
interface ToolbarFrame {
atMs: number;
labels: string[];
}
declare global {
interface Window {
__composerToolbarFrames?: ToolbarFrame[];
__composerToolbarStop?: () => void;
}
}
/** Records every painted frame's visible mode-trigger labels until stopped. */
export async function recordComposerToolbarFrames(page: Page): Promise<void> {
await page.evaluate(() => {
const frames: ToolbarFrame[] = [];
window.__composerToolbarFrames = frames;
const start = performance.now();
let running = true;
const tick = () => {
if (!running) return;
const labels = Array.from(document.querySelectorAll('[data-testid="mode-control"]'))
.filter((node) => node.getClientRects().length > 0)
.map((node) => (node.textContent ?? "").trim());
frames.push({ atMs: Math.round(performance.now() - start), labels });
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
window.__composerToolbarStop = () => {
running = false;
};
});
}
async function readComposerToolbarFrames(page: Page): Promise<ToolbarFrame[]> {
return page.evaluate(() => {
window.__composerToolbarStop?.();
return window.__composerToolbarFrames ?? [];
});
}
export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise<void> {
const frames = await readComposerToolbarFrames(page);
expect(frames.length).toBeGreaterThan(0);
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
}
+23 -1
View File
@@ -7,6 +7,7 @@ import { connectWorkspaceSetupClient } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { getServerId } from "./server-id";
import { waitForTabBar } from "./launcher";
import { waitForSettledPosition } from "./sheet-layout";
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
@@ -71,7 +72,28 @@ export async function pressInterruptShortcut(page: Page): Promise<void> {
export async function openAttachmentMenu(page: Page): Promise<void> {
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
await expect(
page
.getByTestId("message-input-attachment-menu")
.or(page.getByTestId("message-input-attachment-menu-content")),
).toBeVisible({ timeout: 5_000 });
}
export async function expectAttachmentSheetRowsOnTitleRail(page: Page): Promise<void> {
const title = page.getByText("Add attachment", { exact: true });
const firstItemGlyph = page
.getByRole("button", { name: "Add image", exact: true })
.locator("svg")
.first();
await waitForSettledPosition(title);
const [titleBox, glyphBox] = await Promise.all([
title.boundingBox(),
firstItemGlyph.boundingBox(),
]);
if (!titleBox || !glyphBox) {
throw new Error("Attachment sheet geometry could not be measured");
}
expect(Math.abs(glyphBox.x - titleBox.x)).toBeLessThanOrEqual(1);
}
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
+26 -6
View File
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { identityColor, type IdentityColorName } from "@/styles/identity-colors";
import { identityForeground, type IdentityColorName } from "@/styles/identity-colors";
import { openSettings } from "./app";
import { buildSeededHost } from "./daemon-registry";
import { clickSettingsBackToWorkspace, openHostSection, selectSettingsHost } from "./settings";
@@ -123,11 +123,17 @@ export async function waitForConnectedHost(
await expect(host).not.toBeVisible();
}
export async function openSidebarHostFilter(page: Page): Promise<void> {
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
timeout: 10_000,
});
}
// Host filters live a page below the display-preferences root, so the filter rows only exist
// after walking into that branch.
export async function openSidebarHostFilter(page: Page): Promise<void> {
await openSidebarDisplayPreferences(page);
await page.getByTestId("sidebar-display-host-filter").click();
}
@@ -189,7 +195,7 @@ export async function chooseHostBadgeDisplay(
function hostBadge(page: Page, input: HostBadgeTarget) {
return page
.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`)
.getByTestId(`sidebar-host-badge-${input.serverId}`);
.getByTestId(`host-badge-${input.serverId}`);
}
interface HostBadgeTarget {
@@ -217,13 +223,24 @@ export async function expectNoHostBadge(page: Page, target: HostBadgeTarget): Pr
await expect(hostBadge(page, target)).toHaveCount(0, { timeout: 15_000 });
}
// The host sits on the meta line as plain secondary text, so its identity colour lives on the
// server icon alone. The label stays muted like every other item on that line — asserting a
// tinted label or a pill fill would re-assert the design the meta row replaced.
//
// The icon takes the *foreground* variant of the identity colour, not the fill one: it is a
// stroked glyph on a surface, so it has to clear contrast against that surface rather than
// behind white letters. That variant is per-scheme, and the browser project runs light.
export async function expectHostBadgeTinted(
page: Page,
target: HostBadgeTarget & { color: IdentityColorName },
): Promise<void> {
const badge = hostBadge(page, target);
await expect(badge).toBeVisible({ timeout: 15_000 });
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
await expect(badge).toHaveText(target.hostName);
await expect(badge.locator("svg")).toHaveAttribute(
"stroke",
identityForeground(target.color, "light"),
);
}
export async function expectHostAppearancePreview(
@@ -232,9 +249,12 @@ export async function expectHostAppearancePreview(
): Promise<void> {
const preview = page.getByTestId("host-appearance-preview");
await expect(preview).toBeVisible();
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
const badge = preview.getByTestId(`host-badge-${input.serverId}`);
await expect(badge).toHaveText(input.hostName);
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
await expect(badge.locator("svg")).toHaveAttribute(
"stroke",
identityForeground(input.color, "light"),
);
}
// The auto-seed fixture rewrites the host registry on every navigation. Setting the
@@ -0,0 +1,129 @@
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs";
import net from "node:net";
import path from "node:path";
export interface LocalElixirRelay {
endpoint: string;
port: number;
start(): Promise<void>;
stop(): Promise<void>;
close(): Promise<void>;
}
async function availablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Could not allocate a relay port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(force);
}
}
async function waitUntilReady(
port: number,
child: ChildProcess,
recentOutput: () => string,
): Promise<void> {
const deadline = Date.now() + 60_000;
let lastError: unknown;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
);
}
try {
const response = await fetch(`http://127.0.0.1:${port}/ready`, {
signal: AbortSignal.timeout(1_000),
});
if (response.ok) return;
lastError = new Error(`Relay readiness returned HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(
`Elixir relay did not become ready on port ${port}: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${recentOutput()}`,
);
}
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
const relayRoot =
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
throw new Error(
`Expected the Elixir relay checkout at ${relayRoot}. Set PASEO_RELAY_CHECKOUT to override it.`,
);
}
const port = await availablePort();
let child: ChildProcess | null = null;
let output: string[] = [];
const start = async () => {
if (child && child.exitCode === null && child.signalCode === null) {
throw new Error("Elixir relay is already running");
}
output = [];
child = spawn("asdf", ["exec", "mix", "run", "--no-halt"], {
cwd: relayRoot,
env: {
...process.env,
MIX_ENV: "prod",
PASEO_RELAY_HOST: "127.0.0.1",
PASEO_RELAY_PORT: String(port),
PASEO_RELAY_MIN_CLUSTER_SIZE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
const capture = (chunk: Buffer) => {
output.push(chunk.toString("utf8"));
output = output.join("").split("\n").slice(-80);
};
child.stdout?.on("data", capture);
child.stderr?.on("data", capture);
try {
await waitUntilReady(port, child, () => output.join("\n"));
} catch (error) {
await stopProcess(child);
throw error;
}
};
const stop = async () => {
if (!child) return;
await stopProcess(child);
child = null;
};
await start();
return {
endpoint: `127.0.0.1:${port}`,
port,
start,
stop,
close: stop,
};
}
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
export interface MockAgentOptions {
repoPrefix: string;
title: string;
port?: number;
initialPrompt?: string;
model?: string;
modeId?: string;
@@ -28,7 +29,7 @@ export interface MockAgentOptions {
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
try {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -0,0 +1,193 @@
import { expect, type Page } from "@playwright/test";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import { DEFAULT_TERMINAL_PROFILES } from "@getpaseo/protocol/terminal-profiles";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { waitForTerminalContent } from "./terminal-perf";
export type { TerminalProfile };
export interface TerminalProfileSeed {
/** Restores the daemon's terminalProfiles to what they were before seeding, and closes the client. */
restore(): Promise<void>;
}
/**
* Overrides the connected host's terminal profiles for the duration of a
* test. The daemon has no "unset" for a patched config field, so restoring
* writes back either the exact previous array, or the shipped defaults when
* the host had never customized profiles — functionally identical to the
* absent state `resolveTerminalProfiles` would otherwise produce.
*/
export async function seedTerminalProfiles(
profiles: TerminalProfile[],
): Promise<TerminalProfileSeed> {
const client = await connectNewWorkspaceDaemonClient();
const previous = await client.getDaemonConfig();
await client.patchDaemonConfig({ terminalProfiles: profiles });
return {
async restore() {
await client.patchDaemonConfig({
terminalProfiles: previous.config.terminalProfiles ?? [...DEFAULT_TERMINAL_PROFILES],
});
await client.close();
},
};
}
/** Mid-test profile changes (e.g. simulating a profile being removed). Does not manage restoration — pair with `seedTerminalProfiles` for that. */
export async function patchTerminalProfiles(profiles: TerminalProfile[]): Promise<void> {
const client = await connectNewWorkspaceDaemonClient();
try {
await client.patchDaemonConfig({ terminalProfiles: profiles });
} finally {
await client.close();
}
}
// ─── Locators ──────────────────────────────────────────────────────────────
/** The single meta-row chip that chooses chat or a terminal. */
export function launchTrigger(page: Page) {
return page.getByTestId("new-workspace-launch-trigger");
}
export function launchMenu(page: Page) {
return page.getByTestId("new-workspace-launch-menu");
}
export function launchOption(page: Page, optionId: string) {
return page.getByTestId(`new-workspace-launch-option-${optionId}`);
}
export function manageProfilesOption(page: Page) {
return page.getByTestId("new-workspace-launch-manage-profiles");
}
// Chat and terminal are the same composer in two modes, so they share
// `message-input-root`. What tells them apart is which affordances that mode
// renders: chat has the attachment button, terminal has the Launch button.
export function composerRoot(page: Page) {
return page.locator('[data-testid="message-input-root"]:visible');
}
// Scoped to the visible composer: a workspace opened earlier in the test keeps
// its own composer mounted but hidden in the deck, so an unscoped testid
// matches two elements and trips strict mode.
export function attachButton(page: Page) {
return composerRoot(page).getByTestId("message-input-attach-button");
}
export function terminalPromptInput(page: Page) {
return composerRoot(page).getByRole("textbox", { name: "Terminal prompt" });
}
/** The static command shown when the selected profile takes no prompt. */
export function terminalCommandPreview(page: Page) {
return composerRoot(page).getByTestId("composer-readonly-content");
}
export function terminalLaunchSubmit(page: Page) {
return composerRoot(page).getByTestId("new-workspace-launch-submit");
}
// ─── Actions ───────────────────────────────────────────────────────────────
export async function openLaunchMenu(page: Page): Promise<void> {
await launchTrigger(page).click();
await expect(launchMenu(page)).toBeVisible({ timeout: 30_000 });
}
/** `optionId` is `chat`, `blank`, or a terminal profile id. */
export async function selectLaunchOption(page: Page, optionId: string): Promise<void> {
await openLaunchMenu(page);
const option = launchOption(page, optionId);
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expect(launchMenu(page)).toHaveCount(0);
}
export async function selectChatLaunch(page: Page): Promise<void> {
await selectLaunchOption(page, "chat");
}
export async function fillTerminalPrompt(page: Page, text: string): Promise<void> {
const input = terminalPromptInput(page);
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(text);
}
export async function submitTerminalLaunch(page: Page): Promise<void> {
const submit = terminalLaunchSubmit(page);
await expect(submit).toBeEnabled({ timeout: 30_000 });
await submit.click();
}
// ─── Assertions ────────────────────────────────────────────────────────────
export async function expectChatComposerActive(page: Page): Promise<void> {
await expect(composerRoot(page)).toBeVisible({ timeout: 30_000 });
await expect(attachButton(page)).toBeVisible({ timeout: 30_000 });
await expect(terminalLaunchSubmit(page)).toHaveCount(0);
}
export async function expectTerminalComposerActive(page: Page): Promise<void> {
await expect(composerRoot(page)).toBeVisible({ timeout: 30_000 });
await expect(terminalLaunchSubmit(page)).toBeVisible({ timeout: 30_000 });
await expect(attachButton(page)).toHaveCount(0);
}
export async function expectLaunchTriggerLabel(page: Page, label: string): Promise<void> {
await expect(launchTrigger(page)).toContainText(label);
}
export async function expectChatLaunchSelected(page: Page): Promise<void> {
await expectLaunchTriggerLabel(page, "Chat");
await expectChatComposerActive(page);
}
export async function expectTerminalLaunchSelected(
page: Page,
profileLabel: string,
): Promise<void> {
await expectLaunchTriggerLabel(page, profileLabel);
await expectTerminalComposerActive(page);
}
export async function expectTerminalPreviewCommand(page: Page, command: string): Promise<void> {
const preview = terminalCommandPreview(page);
await expect(preview).toBeVisible({ timeout: 30_000 });
await expect(preview).toHaveText(command);
}
function terminalContentIncludes(expected: string) {
return (text: string) => text.includes(expected);
}
/** Waits for the spawned terminal's rendered output to contain `expected`. */
export async function expectTerminalOutputContains(
page: Page,
expected: string,
timeoutMs = 30_000,
): Promise<void> {
await waitForTerminalContent(page, terminalContentIncludes(expected), timeoutMs);
}
/**
* Waits for the New workspace submit to land on a workspace with a terminal
* tab open, and returns the terminal id. Scoped to visible elements: earlier
* workspace decks stay mounted (offscreen) for fast switching, so an
* unscoped terminal-surface locator can match more than one.
*/
export async function expectWorkspaceOpensWithTerminalTab(page: Page): Promise<string> {
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]:visible');
await expect(terminalTab.first()).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="terminal-surface"]:visible').first()).toBeVisible({
timeout: 30_000,
});
const testId = await terminalTab.first().getAttribute("data-testid");
const terminalId = testId?.replace("workspace-tab-terminal_", "") ?? "";
if (!terminalId) {
throw new Error(`Could not parse terminal id from tab testID: ${testId}`);
}
return terminalId;
}
@@ -235,6 +235,33 @@ export async function expectNewWorkspaceProjectSelected(
await expect(projectPicker).toContainText(projectDisplayName);
}
export async function expectNewWorkspaceTriggerLabelsAligned(
page: Page,
input: { projectLabel: string; hostLabel: string },
): Promise<void> {
const projectTrigger = page.getByRole("button", { name: "Workspace project" });
const hostTrigger = page.getByRole("button", { name: "Host", exact: true });
const projectLabel = projectTrigger.getByText(input.projectLabel, { exact: true });
const hostLabel = hostTrigger.getByText(input.hostLabel, { exact: true });
await Promise.all([
expect(projectLabel).toBeVisible({ timeout: 30_000 }),
expect(hostLabel).toBeVisible({ timeout: 30_000 }),
]);
const [projectTriggerBox, projectLabelBox, hostTriggerBox, hostLabelBox] = await Promise.all([
projectTrigger.boundingBox(),
projectLabel.boundingBox(),
hostTrigger.boundingBox(),
hostLabel.boundingBox(),
]);
if (!projectTriggerBox || !projectLabelBox || !hostTriggerBox || !hostLabelBox) {
throw new Error("New Workspace trigger geometry could not be measured");
}
const projectLabelInset = projectLabelBox.x - projectTriggerBox.x;
const hostLabelInset = hostLabelBox.x - hostTriggerBox.x;
expect(hostLabelInset).toBeCloseTo(projectLabelInset, 0);
}
export async function fillNewWorkspaceDraft(page: Page, draft: string): Promise<void> {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
@@ -0,0 +1,111 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface PackagedWebDaemon {
home: string;
origin: string;
port: number;
serverId: string;
pairingOfferUrl(): Promise<string>;
close(): Promise<void>;
}
async function availablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Could not allocate a daemon port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function waitForWebUi(origin: string, home: string): Promise<void> {
const deadline = Date.now() + 30_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const response = await fetch(origin, { signal: AbortSignal.timeout(1_000) });
const body = await response.text();
if (response.ok && body.includes("<html")) return;
lastError = new Error(`Daemon web UI returned HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const log = await readFile(path.join(home, "daemon.log"), "utf8").catch(() => "");
throw new Error(
`Daemon web UI did not become ready: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${log.split("\n").slice(-80).join("\n")}`,
);
}
export async function startPackagedWebDaemon(input: {
relayEndpoint: string;
}): Promise<PackagedWebDaemon> {
const port = await availablePort();
const home = await mkdtemp(path.join(tmpdir(), "paseo-relay-deployment-e2e-"));
const serverId = `relay-deployment-${Date.now().toString(36)}`;
const paseo = path.resolve(__dirname, "../../../../../node_modules/.bin/paseo");
const env: NodeJS.ProcessEnv = {
...process.env,
CI: "true",
NODE_ENV: "development",
PASEO_NODE_ENV: "development",
PASEO_SERVER_ID: serverId,
PASEO_RELAY_ENDPOINT: input.relayEndpoint,
PASEO_RELAY_PUBLIC_ENDPOINT: input.relayEndpoint,
PASEO_RELAY_USE_TLS: "false",
PASEO_RELAY_PUBLIC_USE_TLS: "false",
};
try {
await execFileAsync(
paseo,
["daemon", "start", "--home", home, "--port", String(port), "--relay", "--web-ui"],
{ env },
);
const origin = `http://127.0.0.1:${port}`;
await waitForWebUi(origin, home);
return {
home,
origin,
port,
serverId,
pairingOfferUrl: async () => {
const { stdout } = await execFileAsync(
paseo,
["daemon", "pair", "--home", home, "--relay", "--json"],
{ env },
);
const result = JSON.parse(stdout) as { url?: unknown };
if (typeof result.url !== "string") {
throw new Error(`Pairing command returned no URL: ${stdout}`);
}
return result.url;
},
close: async () => {
await execFileAsync(paseo, ["daemon", "stop", "--home", home], { env }).catch(
() => undefined,
);
await rm(home, { recursive: true, force: true });
},
};
} catch (error) {
await execFileAsync(paseo, ["daemon", "stop", "--home", home], { env }).catch(() => undefined);
await rm(home, { recursive: true, force: true });
throw error;
}
}
@@ -17,49 +17,6 @@ interface PairingHostInput {
endpoint: string;
}
export async function prepareLocalPairingHost(
page: Page,
daemon: IsolatedHostDaemon | OutdatedDaemon,
additionalHosts: PairingHostInput[] = [],
): Promise<void> {
await page.addInitScript((localServerId) => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
if (command === "desktop_daemon_status") {
return {
serverId: localServerId,
status: "running",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
};
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
opener: {
openUrl: async (url: string) => {
localStorage.setItem("@paseo:e2e-opened-url", url);
},
},
};
}, daemon.serverId);
await preparePairingHost(page, daemon, additionalHosts);
}
export async function preparePairingHost(
page: Page,
daemon: IsolatedHostDaemon | OutdatedDaemon,
@@ -0,0 +1,131 @@
import { expect, type Page } from "@playwright/test";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import type { LocalElixirRelay } from "./local-elixir-relay";
import type { PackagedWebDaemon } from "./packaged-web-daemon";
import { expectRunningAgentChrome } from "./agent-stream";
import { buildCreateAgentPreferences } from "./daemon-registry";
export interface RelayDeploymentMeasurements {
reconnectToastDelayMs: number;
reconnectToastVisibleMs: number;
totalReconnectMs: number;
relayStartupMs: number;
streamPausedWhileDisconnected: boolean;
runningStatePreserved: boolean;
streamResumedWithoutUserAction: boolean;
}
export async function connectDaemonWebAppOnlyThroughRelay(
page: Page,
daemon: PackagedWebDaemon,
): Promise<void> {
const offerUrl = await daemon.pairingOfferUrl();
const offer = parseConnectionOfferFromUrl(offerUrl);
if (!offer) throw new Error("Daemon pairing command returned an invalid offer URL");
const relayConnection = {
id: `relay:${offer.relay.endpoint}`,
type: "relay" as const,
relayEndpoint: offer.relay.endpoint,
useTls: offer.relay.useTls ?? false,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
};
const now = new Date().toISOString();
const host = {
serverId: offer.serverId,
label: "Relay deployment host",
connections: [relayConnection],
preferredConnectionId: relayConnection.id,
createdAt: now,
updatedAt: now,
};
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (socket) => {
await socket.close({ code: 1008, reason: "Blocked developer daemon during relay E2E" });
});
await page.route("**/*", async (route) => {
if (route.request().resourceType() !== "document") {
await route.continue();
return;
}
const response = await route.fetch();
const html = await response.text();
await route.fulfill({
response,
body: html.replace(/<script>window\.__PASEO_INITIAL_DAEMON_CONNECTION__=.*?<\/script>/, ""),
});
});
await page.addInitScript(
({ storedHost, preferences }) => {
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) },
);
const relaySocketOpened = new Promise<void>((resolve) => {
page.on("websocket", (socket) => {
if (socket.url().includes(offer.relay.endpoint)) resolve();
});
});
await page.goto(daemon.origin);
await relaySocketOpened;
await expect(page.getByTestId("sidebar-settings")).toBeVisible({ timeout: 30_000 });
}
async function latestAssistantText(page: Page): Promise<string> {
return (await page.getByTestId("assistant-message").last().innerText()).trim();
}
async function waitForAssistantTextToGrow(page: Page, previous: string): Promise<void> {
await expect.poll(() => latestAssistantText(page), { timeout: 30_000 }).not.toBe(previous);
}
async function expectRunningStatusPreserved(page: Page, agentTitle: string): Promise<void> {
const tab = page.getByRole("button", { name: agentTitle, exact: true });
await expect(tab.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
}
export async function measureRelayRestartDuringStream(input: {
page: Page;
relay: LocalElixirRelay;
agentTitle: string;
}): Promise<RelayDeploymentMeasurements> {
const { page, relay, agentTitle } = input;
const toast = page.getByTestId("agent-reconnecting-toast");
const beforeOutage = await latestAssistantText(page);
await waitForAssistantTextToGrow(page, beforeOutage);
const outageStartedAt = performance.now();
const toastVisibleAtPromise = toast
.waitFor({ state: "visible", timeout: 30_000 })
.then(() => performance.now());
await relay.stop();
const toastVisibleAt = await toastVisibleAtPromise;
await expectRunningStatusPreserved(page, agentTitle);
await page.waitForTimeout(750);
const disconnectedText = await latestAssistantText(page);
await page.waitForTimeout(1_000);
const streamPausedWhileDisconnected = (await latestAssistantText(page)) === disconnectedText;
expect(streamPausedWhileDisconnected).toBe(true);
const relayStartAt = performance.now();
await relay.start();
const relayReadyAt = performance.now();
await toast.waitFor({ state: "hidden", timeout: 30_000 });
const reconnectedAt = performance.now();
await expectRunningAgentChrome(page, agentTitle);
await waitForAssistantTextToGrow(page, disconnectedText);
return {
reconnectToastDelayMs: Math.round(toastVisibleAt - outageStartedAt),
reconnectToastVisibleMs: Math.round(reconnectedAt - toastVisibleAt),
totalReconnectMs: Math.round(reconnectedAt - outageStartedAt),
relayStartupMs: Math.round(relayReadyAt - relayStartAt),
streamPausedWhileDisconnected,
runningStatePreserved: true,
streamResumedWithoutUserAction: true,
};
}
@@ -39,7 +39,6 @@ export interface SeedDaemonClient {
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
renameProject(projectId: string, customName: string | null): Promise<void>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
@@ -86,6 +86,18 @@ export async function selectHostConnectionType(
await page.getByRole("button", { name: label }).click();
}
export async function addDirectHostFromSettings(
page: Page,
input: { host: string; port: number },
): Promise<void> {
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await page.getByTestId("direct-host-input").fill(input.host);
await page.getByTestId("direct-port-input").fill(String(input.port));
await page.getByTestId("direct-host-submit").click();
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
}
export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click();
}
@@ -166,6 +166,19 @@ export async function expectWorkspaceAbsentFromSidebar(
).toHaveCount(0, { timeout: 30_000 });
}
// The display-preferences menu is one row per decision, and the options sit a page below that
// row. Every caller has to walk the same path, so it lives here: when the menu's shape moves
// again, this is the only place that has to follow.
export async function openSidebarDisplayPage(page: Page, branchTestID: string): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId(branchTestID).click();
}
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
await openSidebarDisplayPage(page, "sidebar-display-grouping");
await page.getByTestId("sidebar-grouping-status").click();
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click();
}
@@ -95,9 +95,12 @@ export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
const toast = page.getByTestId("agent-reconnecting-toast");
await expect(toast).toBeVisible({
timeout: options?.timeout ?? 30_000,
});
await expect(toast).toHaveText("Reconnecting");
await expect(toast.getByTestId("agent-reconnecting-status-dot")).toBeVisible();
}
export async function expectReconnectingToastGone(
@@ -109,6 +112,14 @@ export async function expectReconnectingToastGone(
});
}
export async function expectReconnectingToastDebounced(
page: Page,
options?: { durationMs?: number },
): Promise<void> {
await page.waitForTimeout(options?.durationMs ?? 750);
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
}
export async function expectHostConnectingOrOffline(
page: Page,
options?: { timeout?: number },
@@ -0,0 +1,15 @@
apply plugin: 'com.android.library'
group = 'sh.paseo.trace'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.trace"
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,19 @@
package sh.paseo.trace
import android.os.Trace
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoNativeTraceModule : Module() {
override fun definition() = ModuleDefinition {
Name("PaseoNativeTrace")
Function("beginSection") { name: String ->
Trace.beginSection(name.take(127))
}
Function("endSection") {
Trace.endSection()
}
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["sh.paseo.trace.PaseoNativeTraceModule"]
}
}
@@ -0,0 +1,5 @@
{
"name": "paseo-native-trace",
"version": "0.1.0",
"private": true
}
+41
View File
@@ -0,0 +1,41 @@
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
const stableIosBuildSlot = 999;
function getNativeReleaseVersion(version) {
const match = versionPattern.exec(version);
if (!match) {
throw new Error(`Cannot derive native release version from unsupported version: ${version}`);
}
const [, majorText, minorText, patchText, betaText] = match;
const major = Number(majorText);
const minor = Number(minorText);
const patch = Number(patchText);
const betaNumber = betaText === undefined ? null : Number(betaText);
if (minor > 999 || patch > 999) {
throw new Error(`Cannot derive collision-free native version from: ${version}`);
}
if (betaNumber !== null && (betaNumber < 1 || betaNumber >= stableIosBuildSlot)) {
throw new Error(`iOS beta number must be between 1 and 998: ${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}`);
}
const iosBuildSlot = betaNumber ?? stableIosBuildSlot;
const iosBuildNumber = versionCode * 1_000 + iosBuildSlot;
if (!Number.isSafeInteger(iosBuildNumber)) {
throw new Error(`Derived iOS buildNumber is out of range: ${iosBuildNumber}`);
}
return {
appVersion: `${major}.${minor}.${patch}`,
androidVersionCode: versionCode,
iosBuildNumber: String(iosBuildNumber),
};
}
module.exports = { getNativeReleaseVersion };
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
const { getNativeReleaseVersion } = require("./native-release-version");
describe("native release version", () => {
it("reserves the final iOS build slot for a stable release", () => {
expect(getNativeReleaseVersion("0.2.6")).toEqual({
appVersion: "0.2.6",
androidVersionCode: 2006,
iosBuildNumber: "2006999",
});
});
it("gives each beta a unique iOS build slot under the stable app version", () => {
expect(getNativeReleaseVersion("0.2.6-beta.2")).toEqual({
appVersion: "0.2.6",
androidVersionCode: 2006,
iosBuildNumber: "2006002",
});
});
it("rejects beta numbers that consume the stable iOS build slot", () => {
expect(() => getNativeReleaseVersion("0.2.6-beta.999")).toThrow(
"iOS beta number must be between 1 and 998",
);
});
});
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.5",
"version": "0.3.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -23,6 +23,7 @@
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project=browser",
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
"test:e2e:relay-deployment": "npm --prefix ../.. run build:server && npm --prefix ../.. run build:daemon-web-ui && cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=relay-deployment e2e/browser/relay-deployment-reconnect.real.spec.ts",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
@@ -100,7 +101,7 @@
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-keyboard-controller": "1.21.12",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.3.1",
+7
View File
@@ -4,6 +4,7 @@ import { defineConfig, devices } from "@playwright/test";
// This allows multiple test runs in parallel across different worktrees
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
export default defineConfig({
testDir: "./e2e/browser",
@@ -33,6 +34,12 @@ export default defineConfig({
{
name: "real-provider",
testMatch: ["**/*.real.spec.ts"],
testIgnore: [relayDeploymentSpec],
use: { ...devices["Desktop Chrome"] },
},
{
name: "relay-deployment",
testMatch: [relayDeploymentSpec],
use: { ...devices["Desktop Chrome"] },
},
],
@@ -0,0 +1,15 @@
const { withAndroidManifest } = require("expo/config-plugins");
function withAndroidProfileable(config) {
return withAndroidManifest(config, (modConfig) => {
const application = modConfig.modResults.manifest.application?.[0];
if (!application) {
throw new Error("Could not enable Android profiling without an application manifest entry");
}
application.profileable = [{ $: { "android:shell": "true" } }];
return modConfig;
});
}
module.exports = withAndroidProfileable;
@@ -12,7 +12,7 @@ import type { ChatOutlineRailProps } from "./rail";
// magnifying a slot must not move the box the pointer is resting on. See docs/hover.md.
const RAIL_WIDTH = 36;
const SLOT_HEIGHT = 8;
const MIN_PANEL_WIDTH = 818;
const MIN_PANEL_WIDTH = 918;
const RESTING_PILL_HEIGHT = 2;
const MAGNIFIED_PILL_HEIGHT = 4;
const RESTING_PILL_WIDTH = 10;
@@ -1031,7 +1031,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
minHeight: 0,
overflowX: "hidden",
overflowY: scrollEnabled ? "auto" : "hidden",
overflowAnchor: "none",
overscrollBehaviorY: "contain",
};
}, [scrollEnabled]);
@@ -22,12 +22,7 @@ import { SyncedLoader } from "@/components/synced-loader";
import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -0,0 +1,464 @@
import { afterEach, describe, expect, it } from "vitest";
import { createAssistantSelectionClipboardContent } from "./content.web";
const fixture = `
<div data-testid="assistant-message">
<div data-paseo-markdown-tag="p">Prefix <span data-paseo-markdown-tag="strong">bold text</span> and <span data-paseo-markdown-tag="code">inline code</span> suffix.</div>
<div data-paseo-markdown-tag="ul">
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>First bullet text</span></div></div>
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>Second bullet text</span></div></div>
</div>
<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="ts"><span data-paseo-markdown-tag="code">const answer = true;</span></div>
</div>
`;
function mountFixture(): HTMLElement {
const host = document.createElement("div");
host.innerHTML = fixture;
document.body.append(host);
const message = host.querySelector<HTMLElement>('[data-testid="assistant-message"]');
if (!message) {
throw new Error("Expected assistant message fixture");
}
return message;
}
function fixtureElement<T extends Element = HTMLElement>(
root: ParentNode,
selector: string,
index = 0,
): T {
const element = root.querySelectorAll<T>(selector).item(index);
if (!element) {
throw new Error(`Expected fixture element ${selector} at index ${index}`);
}
return element;
}
function textNode(element: Element): Text {
const node = element.firstChild;
if (!(node instanceof Text)) {
throw new Error("Expected text node");
}
return node;
}
function selectText(element: Element, start: number, end: number): Selection {
const node = textNode(element);
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const selection = window.getSelection();
if (!selection) {
throw new Error("Expected browser selection");
}
selection.removeAllRanges();
selection.addRange(range);
return selection;
}
function selectRange(
startElement: Element,
startOffset: number,
endElement: Element,
endOffset: number,
): Selection {
const range = document.createRange();
range.setStart(textNode(startElement), startOffset);
range.setEnd(textNode(endElement), endOffset);
const selection = window.getSelection();
if (!selection) {
throw new Error("Expected browser selection");
}
selection.removeAllRanges();
selection.addRange(range);
return selection;
}
function selectNodeContents(element: Element): Selection {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
if (!selection) {
throw new Error("Expected browser selection");
}
selection.removeAllRanges();
selection.addRange(range);
return selection;
}
function copiedMarkdown(selection: Selection): string | null {
return createAssistantSelectionClipboardContent(selection)?.plainText ?? null;
}
afterEach(() => {
window.getSelection()?.removeAllRanges();
document.body.replaceChildren();
});
describe("assistant selection copy ranges", () => {
it("does not handle absent, collapsed, or non-assistant selections", () => {
expect(createAssistantSelectionClipboardContent(null)).toBeNull();
const message = mountFixture();
const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
expect(copiedMarkdown(selectText(strong, 2, 2))).toBeNull();
const outside = document.createElement("span");
outside.textContent = "outside";
document.body.append(outside);
expect(copiedMarkdown(selectText(outside, 0, 7))).toBeNull();
});
it("does not replace the browser clipboard for a range spanning assistant messages", () => {
const firstMessage = mountFixture();
const secondMessage = mountFixture();
const firstText = fixtureElement(firstMessage, '[data-paseo-markdown-tag="strong"]');
const secondText = fixtureElement(secondMessage, '[data-paseo-markdown-tag="strong"]');
expect(copiedMarkdown(selectRange(firstText, 0, secondText, 4))).toBeNull();
});
it("preserves all structure when the complete assistant message is selected", () => {
const message = mountFixture();
const content = createAssistantSelectionClipboardContent(selectNodeContents(message));
expect(content?.plainText).toBe(
[
"Prefix **bold text** and `inline code` suffix.",
"",
"- First bullet text",
"- Second bullet text",
"",
"```ts",
"const answer = true;",
"```",
].join("\n"),
);
expect(content?.html).not.toContain("<ul>");
expect(content?.html).not.toContain("<li>");
expect(content?.html).toContain("<div>- First bullet text</div>");
expect(content?.html).toContain("<div>- Second bullet text</div>");
});
it.each([
{ tag: "strong", range: [1, 5], expected: "old", forbiddenHtml: "<strong>" },
{ tag: "code", range: [2, 8], expected: "line c", forbiddenHtml: "<code>" },
])(
"copies a partial $tag range without expanding to its delimiters",
({ tag, range, expected, forbiddenHtml }) => {
const message = mountFixture();
const element = fixtureElement(message, `[data-paseo-markdown-tag="${tag}"]`);
const content = createAssistantSelectionClipboardContent(
selectText(element, range[0], range[1]),
);
expect(content?.plainText).toBe(expected);
expect(content?.html).not.toContain(forbiddenHtml);
},
);
it.each(["strong", "em", "s", "h2", "blockquote"])(
"copies all content selected from inside a %s element without its syntax",
(tag) => {
const message = mountFixture();
const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
element.setAttribute("data-paseo-markdown-tag", tag);
const content = createAssistantSelectionClipboardContent(
selectText(element, 0, textNode(element).length),
);
expect(content?.plainText).toBe("bold text");
expect(content?.html).not.toContain(`<${tag}>`);
},
);
it("copies complete inline code without delimiters when the selection stays inside", () => {
const message = mountFixture();
const element = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
const content = createAssistantSelectionClipboardContent(
selectText(element, 0, textNode(element).length),
);
expect(content?.plainText).toBe("inline code");
expect(content?.html).not.toContain("<code>");
});
it("keeps a complete inline node but drops formatting from a partial node at the other edge", () => {
const message = mountFixture();
const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
const code = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
expect(copiedMarkdown(selectRange(strong, 0, code, 6))).toBe("**bold text** and inline");
});
it("copies list-item text without inventing a bullet", () => {
const message = mountFixture();
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
const content = createAssistantSelectionClipboardContent(
selectText(itemText, 0, textNode(itemText).length),
);
expect(content?.plainText).toBe("First bullet text");
expect(content?.html).toContain("<p>First bullet text</p>");
expect(content?.html).not.toContain("<li>");
});
it("retains a bullet when the range includes the marker and the complete item", () => {
const message = mountFixture();
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
expect(copiedMarkdown(selectNodeContents(item))).toBe("- First bullet text");
});
it("preserves paragraph breaks when rich HTML flattens a loose list item", () => {
const message = mountFixture();
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
item.replaceChildren();
item.insertAdjacentHTML(
"beforeend",
'<div data-paseo-markdown-tag="p">First paragraph</div><div data-paseo-markdown-tag="p">Second paragraph</div>',
);
const content = createAssistantSelectionClipboardContent(selectNodeContents(item));
expect(content?.plainText).toBe("- First paragraph\n\n Second paragraph");
expect(content?.html).toMatch(/<div>-\s*First paragraph\s*<br><br>Second paragraph\s*<\/div>/);
});
it("omits a partial leading bullet and retains the marker crossed before the trailing item", () => {
const message = mountFixture();
const selector = '[data-paseo-markdown-tag="li"] div span';
const first = fixtureElement(message, selector);
const second = fixtureElement(message, selector, 1);
expect(copiedMarkdown(selectRange(first, 6, second, textNode(second).length))).toBe(
"bullet text\n\n- Second bullet text",
);
});
it("copies part of a fenced code block without adding an inline or fenced delimiter", () => {
const message = mountFixture();
const blockCode = fixtureElement(
message,
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
);
const content = createAssistantSelectionClipboardContent(selectText(blockCode, 6, 12));
expect(content?.plainText).toBe("answer");
expect(content?.html).not.toContain("<code>");
expect(content?.html).not.toContain("<pre>");
});
it("copies code without a fence when every character is selected inside the block", () => {
const message = mountFixture();
const blockCode = fixtureElement(
message,
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
);
expect(copiedMarkdown(selectText(blockCode, 0, textNode(blockCode).length))).toBe(
"const answer = true;",
);
});
});
/**
* The fixture above holds a code block in one text node. The real renderer splits a
* highlighted line across one text node per syntax token and emits each newline as a
* node of its own (`highlighted-code-block.tsx`), and it puts the hover Copy button
* inside the `pre`. Line breaks and indentation only survive if the code skips
* Turndown, so those cases need a fixture shaped like the real thing.
*/
function highlightedFixture(language: string | null): string {
const languageAttribute =
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
return [
'<div data-testid="assistant-message">',
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
'<span data-paseo-markdown-tag="code">',
"<span>const</span><span> answer</span><span> = 1;</span>",
"<span>\n</span>",
"<span> if</span><span> (answer)</span><span> {</span>",
"<span>\n</span>",
"<span> doThing();</span>",
"</span>",
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
"</div>",
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
"</div>",
].join("");
}
function escapeAttribute(value: string): string {
const holder = document.createElement("div");
holder.setAttribute("data-value", value);
return holder.outerHTML.slice('<div data-value="'.length, -"></div>".length);
}
function mountHighlighted(language: string | null = "typescript"): HTMLElement {
const host = document.createElement("div");
host.innerHTML = highlightedFixture(language);
document.body.append(host);
const message = host.querySelector<HTMLElement>('[data-testid="assistant-message"]');
if (!message) {
throw new Error("Expected assistant message fixture");
}
return message;
}
/** The single text node whose content is exactly `text`. */
function tokenText(root: Node, text: string): Text {
const matches = allTextNodes(root, text);
if (matches.length !== 1) {
throw new Error(`Expected exactly one text node "${text}", found ${matches.length}`);
}
return matches[0];
}
function allTextNodes(root: Node, text: string): Text[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const matches: Text[] = [];
while (walker.nextNode()) {
if (walker.currentNode.textContent === text) {
matches.push(walker.currentNode as Text);
}
}
return matches;
}
/** The standalone `\n` node that ends rendered code line `index`. */
function lineBreak(root: Node, index: number): Text {
const breaks = allTextNodes(root, "\n");
const node = breaks[index];
if (!node) {
throw new Error(`Expected a line break at index ${index}, found ${breaks.length}`);
}
return node;
}
function copyBetween(start: [Text, number], end: [Text, number]) {
const range = document.createRange();
range.setStart(start[0], start[1]);
range.setEnd(end[0], end[1]);
const selection = window.getSelection();
if (!selection) {
throw new Error("Expected browser selection");
}
selection.removeAllRanges();
selection.addRange(range);
return createAssistantSelectionClipboardContent(selection);
}
/** Select from the start of one text node to the end of another. */
function copyAcross(root: Node, startText: string, endText: string) {
return copyBetween([tokenText(root, startText), 0], [tokenText(root, endText), endText.length]);
}
describe("assistant selection copy inside highlighted code", () => {
it("keeps the line break and the indentation of a partial multi-line selection", () => {
const message = mountHighlighted();
const content = copyAcross(message, "const", " {");
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {");
});
it("keeps multi-line code as a block in the html half so the lines survive", () => {
const message = mountHighlighted();
const content = copyAcross(message, "const", " {");
expect(content?.html).toBe(
'<meta charset="utf-8"><pre><code class="language-typescript">const answer = 1;\n if (answer) {</code></pre>',
);
});
it("does not escape Markdown characters inside copied code", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, " = 1;"), 1], [tokenText(message, " = 1;"), 5]);
expect(content?.plainText).toBe("= 1;");
});
it("handles a common ancestor that is an element rather than a text node", () => {
const message = mountHighlighted();
// Spanning two token spans makes `commonAncestorContainer` the `code` element.
const content = copyBetween(
[tokenText(message, "const"), 1],
[tokenText(message, " answer"), 4],
);
expect(content?.plainText).toBe("onst ans");
});
it("drops a trailing line break swept in from the end of a line", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, " = 1;"), 1], [lineBreak(message, 0), 1]);
expect(content?.plainText).toBe("= 1;");
});
it("drops a trailing line break together with the indentation after it", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 2]);
expect(content?.plainText).toBe("const answer = 1;");
});
it("keeps line breaks that are not at the end of the selection", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 4]);
expect(content?.plainText).toBe("const answer = 1;\n if");
});
it("leaves the hover copy button out of the copied code", () => {
const message = mountHighlighted();
const content = copyAcross(message, " answer", "Copy");
expect(content?.plainText).toBe(" answer = 1;\n if (answer) {\n doThing();");
expect(content?.plainText).not.toContain("Copy");
});
it("does not treat a selection inside the copy button as code", () => {
const message = mountHighlighted();
expect(copyAcross(message, "Copy", "Copy")).toBeNull();
});
it("omits the language class when the fence carries no info string", () => {
const message = mountHighlighted(null);
const content = copyAcross(message, "const", " {");
expect(content?.html).toBe(
'<meta charset="utf-8"><pre><code>const answer = 1;\n if (answer) {</code></pre>',
);
});
it("escapes a fence info string that would break out of the class attribute", () => {
const message = mountHighlighted('ts" onload="x');
const content = copyAcross(message, "const", " {");
expect(content?.html).toContain('class="language-ts&quot; onload=&quot;x"');
expect(content?.html).not.toContain('onload="x"');
});
it("copies a fully selected multi-line region as code when the selection stays inside", () => {
const message = mountHighlighted();
const blockCode = fixtureElement(
message,
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
);
expect(copiedMarkdown(selectNodeContents(blockCode))).toBe(
"const answer = 1;\n if (answer) {\n doThing();",
);
});
it("retains the fence when a complete code block is selected across its boundary", () => {
const message = mountHighlighted();
const content = copyAcross(message, "const", "After the block.");
expect(content?.plainText).toBe(
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
});
@@ -1,6 +1,7 @@
import TurndownService from "turndown";
import { gfm } from "turndown-plugin-gfm";
import {
createCodeClipboardContent,
createMarkdownClipboardContent,
type MarkdownClipboardContent,
} from "@/utils/rich-clipboard";
@@ -11,9 +12,12 @@ import {
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
MARKDOWN_COPY_TAG_ATTRIBUTE,
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
TRAILING_CODE_LINE_BREAKS,
} from "./markup";
const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]';
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
const turndown = new TurndownService({
bulletListMarker: "-",
@@ -65,66 +69,306 @@ export function createAssistantSelectionClipboardContent(
return null;
}
const partialCode = createPartialCodeContent(range, startMessage);
if (partialCode) {
return partialCode;
}
const container = document.createElement("div");
const selected = wrapSelectionWithAncestors(range, startMessage);
normalizeOrderedListStarts(selected, range, startMessage);
const selected = cloneMarkdownSelection(range, startMessage);
container.append(selected);
restoreMarkdownElements(container);
const markdown = turndown.turndown(container.innerHTML).trim();
return markdown ? createMarkdownClipboardContent(markdown) : null;
if (!markdown) {
return null;
}
const content = createMarkdownClipboardContent(markdown);
return { ...content, html: flattenClipboardListMarkup(content.html) };
}
function wrapSelectionWithAncestors(range: Range, message: Element): Node {
let selected: Node = range.cloneContents();
/**
* Partly-selected code copies as the code itself, never through Turndown.
*
* `removeUnselectedSemantics` strips the `pre`/`code` tags from a partial selection,
* which is right for the Markdown syntax but leaves the code being serialized as
* prose: Turndown collapses the indentation and the line breaks, and escapes
* Markdown-significant characters inside it. Two selected lines arrive as one.
*
* A selection contained inside code always copies as code, even when it contains every
* character. A selection that crosses the code boundary stays on the Markdown path so
* a complete block retains its fence.
*/
function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null {
const region = closestCodeRegion(range.commonAncestorContainer, message);
if (!region) {
return null;
}
const code = selectedCodeText(range);
if (!code) {
return null;
}
// `closest` stops at the inner `code` span, but the fence language lives on the
// outer `pre` wrapper.
const fence = region.closest(CODE_BLOCK_SELECTOR);
// Only multi-line code earns markup in the html half, and only because HTML would
// otherwise collapse the newlines. A single line stays undecorated, like every
// other partial selection.
const block = Boolean(fence) && code.includes("\n");
return createCodeClipboardContent(code, {
language: fence?.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE),
block,
});
}
function closestCodeRegion(node: Node, message: Element): Element | null {
const element = node instanceof Element ? node : node.parentElement;
const region = element?.closest(CODE_REGION_SELECTOR) ?? null;
if (!region || !message.contains(region)) {
return null;
}
// A selection sitting entirely inside the block's own hover Copy button is not
// code. `cloneContents` of a single text node drops the ignore marker with the
// element that carried it, so this has to be asked of the live DOM.
return element?.closest(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`) ? null : region;
}
function selectedCodeText(range: Range): string {
const fragment = range.cloneContents();
// The hover Copy button lives inside the `pre`, so a selection that reaches the
// end of the block sweeps it up too.
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
ignored.remove();
}
// Deliberately untrimmed — leading indentation is part of the code. Trailing line
// breaks are the exception: newlines are their own text nodes between rendered
// lines, so a selection that overshoots the end of a line sweeps one in.
return (fragment.textContent ?? "").replace(TRAILING_CODE_LINE_BREAKS, "");
}
function flattenClipboardListMarkup(html: string): string {
const container = document.createElement("div");
container.innerHTML = html;
const lists = Array.from(container.querySelectorAll("ul, ol")).toReversed();
for (const list of lists) {
const replacement = document.createDocumentFragment();
const items = Array.from(list.children).filter((child) => child.tagName === "LI");
const start = list.tagName === "OL" ? Number(list.getAttribute("start") ?? 1) : null;
items.forEach((item, index) => {
const line = document.createElement("div");
line.append(start === null ? "- " : `${start + index}. `);
const nestedLines: HTMLDivElement[] = [];
let hasParagraph = false;
for (const child of Array.from(item.childNodes)) {
if (child instanceof HTMLDivElement) {
nestedLines.push(child);
} else if (child instanceof HTMLParagraphElement) {
if (hasParagraph) {
line.append(document.createElement("br"), document.createElement("br"));
}
line.append(...child.childNodes);
hasParagraph = true;
} else {
line.append(child);
}
}
replacement.append(line, ...nestedLines);
});
list.replaceWith(replacement);
}
return container.innerHTML;
}
function cloneMarkdownSelection(range: Range, message: Element): Node {
const clonedMessage = message.cloneNode(true) as Element;
const clonedRange = cloneRangeInto(range, message, clonedMessage);
removeUnselectedSemantics(range, message, clonedMessage);
let selected: Node = clonedRange.cloneContents();
let ancestor =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
while (ancestor && ancestor !== message) {
const wrapper = ancestor.cloneNode(false) as Element;
wrapper.append(selected);
selected = wrapper;
if (shouldPreserveSemanticElement(range, ancestor)) {
const wrapper = ancestor.cloneNode(false) as Element;
normalizeOrderedListStart(wrapper, ancestor, range);
wrapper.append(selected);
selected = wrapper;
}
ancestor = ancestor.parentElement;
}
return selected;
}
function normalizeOrderedListStarts(selected: Node, range: Range, message: Element): void {
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="ol"]`;
const originals = Array.from(message.querySelectorAll(selector)).filter((list) =>
range.intersectsNode(list),
function cloneRangeInto(range: Range, source: Element, clone: Element): Range {
const clonedRange = document.createRange();
clonedRange.setStart(
nodeAtPath(clone, nodePath(source, range.startContainer)),
range.startOffset,
);
const copies = markedElements(selected, selector);
for (let index = 0; index < Math.min(originals.length, copies.length); index += 1) {
const original = originals[index];
const firstSelectedItem = Array.from(original.children).find(
(child) =>
child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" && range.intersectsNode(child),
);
if (!firstSelectedItem) {
continue;
}
const omittedItems = Array.from(original.children)
.slice(0, Array.from(original.children).indexOf(firstSelectedItem))
.filter((child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li").length;
const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
copies[index].setAttribute(
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
String(originalStart + omittedItems),
);
}
clonedRange.setEnd(nodeAtPath(clone, nodePath(source, range.endContainer)), range.endOffset);
return clonedRange;
}
function markedElements(root: Node, selector: string): Element[] {
const elements = root instanceof Element && root.matches(selector) ? [root] : [];
if ("querySelectorAll" in root) {
elements.push(...Array.from((root as ParentNode).querySelectorAll(selector)));
function nodePath(root: Node, node: Node): number[] {
const path: number[] = [];
let current = node;
while (current !== root) {
const parent = current.parentNode;
if (!parent) {
throw new Error("Selection boundary is outside its assistant message");
}
path.unshift(Array.from(parent.childNodes).findIndex((child) => child === current));
current = parent;
}
return elements;
return path;
}
function nodeAtPath(root: Node, path: number[]): Node {
let current = root;
for (const index of path) {
const child = current.childNodes[index];
if (!child) {
throw new Error("Cloned assistant message does not match the selection source");
}
current = child;
}
return current;
}
function removeUnselectedSemantics(range: Range, source: Element, clone: Element): void {
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a`;
const originals = Array.from(source.querySelectorAll(selector));
const copies = Array.from(clone.querySelectorAll(selector));
originals.forEach((original, index) => {
const copy = copies[index];
if (!copy || shouldPreserveSemanticElement(range, original)) {
if (copy) {
normalizeOrderedListStart(copy, original, range);
}
return;
}
const tag = original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
if (tag && isBlockBoundary(tag)) {
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, "p");
return;
}
copy.removeAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
if (copy.tagName === "A") {
copy.setAttribute(MARKDOWN_COPY_UNWRAP_ATTRIBUTE, "true");
}
});
}
function shouldPreserveSemanticElement(range: Range, element: Element): boolean {
if (!range.intersectsNode(element)) {
return false;
}
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
if (tag === "p" || isTableStructure(tag)) {
return true;
}
const isSelectableSemantic = tag !== null && tag !== "ol" && tag !== "ul";
if (
(isSelectableSemantic || element.tagName === "A") &&
selectionStaysInsideElement(range, element)
) {
return false;
}
if (tag === "ol" || tag === "ul") {
const selectedItems = selectedListItems(element, range);
return selectedItems.some((item) => hasSelectedAllContents(range, item, true));
}
return hasSelectedAllContents(range, element, tag === "li");
}
function selectionStaysInsideElement(range: Range, element: Element): boolean {
return (
range.startContainer !== element &&
range.endContainer !== element &&
element.contains(range.startContainer) &&
element.contains(range.endContainer)
);
}
function selectedListItems(list: Element, range: Range): Element[] {
return Array.from(list.children).filter(
(child) =>
child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" &&
(child.contains(range.startContainer) ||
child.contains(range.endContainer) ||
hasSelectedAllContents(range, child, true)),
);
}
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
return;
}
const firstSelectedItem = selectedListItems(original, range)[0];
if (!firstSelectedItem) {
return;
}
const items = Array.from(original.children).filter(
(child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li",
);
const omittedItems = items.indexOf(firstSelectedItem);
const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, String(originalStart + omittedItems));
}
function hasSelectedAllContents(range: Range, element: Element, includeIgnored = false): boolean {
const contents = document.createRange();
contents.selectNodeContents(element);
if (range.compareBoundaryPoints(Range.START_TO_START, contents) > 0) {
const before = contents.cloneRange();
before.setEnd(range.startContainer, range.startOffset);
if (hasMarkdownContent(before.cloneContents(), includeIgnored)) {
return false;
}
}
if (range.compareBoundaryPoints(Range.END_TO_END, contents) < 0) {
const after = contents.cloneRange();
after.setStart(range.endContainer, range.endOffset);
if (hasMarkdownContent(after.cloneContents(), includeIgnored)) {
return false;
}
}
return true;
}
function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
if (!includeIgnored) {
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
ignored.remove();
}
}
if (fragment.textContent) {
return true;
}
const visibleVoidSelector = ["br", "hr"]
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
.join(",");
return Boolean(fragment.querySelector(visibleVoidSelector));
}
function isBlockBoundary(tag: string): boolean {
return tag === "li" || tag === "blockquote" || tag === "pre" || /^h[1-6]$/.test(tag);
}
function isTableStructure(tag: string | null): boolean {
return ["table", "thead", "tbody", "tr", "th", "td"].includes(tag ?? "");
}
function closestAssistantMessage(node: Node): Element | null {
@@ -5,6 +5,16 @@ export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-star
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
/**
* Trailing line breaks, with any indentation that followed the last one.
*
* Both ways of copying code strip these, for the same reason: pasting a trailing
* newline into a terminal runs the last line. A fence body always ends in one, and
* ends in several when the author left blank lines before the closing fence; a
* selection picks one up whenever it overshoots the end of a rendered line.
*/
export const TRAILING_CODE_LINE_BREAKS = /(\r?\n[ \t]*)+$/;
export const markdownCopyDataSet = {
blockquote: { paseoMarkdownTag: "blockquote" },
br: { paseoMarkdownTag: "br" },
+201 -20
View File
@@ -1,9 +1,10 @@
import { useCallback, useMemo, useReducer, useState } from "react";
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import { getIsElectron, isNative } from "@/constants/platform";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2, Plus, X } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import {
@@ -12,6 +13,11 @@ import {
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import {
prepareConnectionHeaders,
type ConnectionHeaderDraft,
type ConnectionHeaderValidationIssue,
} from "@/utils/connection-headers";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -123,6 +129,30 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
advancedContent: {
gap: theme.spacing[4],
},
headerSection: {
gap: theme.spacing[2],
},
headerTitleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
headerList: {
gap: theme.spacing[2],
},
headerRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerInput: {
flex: 1,
minWidth: 0,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
@@ -181,6 +211,94 @@ function prepareDirectConnection(
};
}
function headerIssueMessage(
issue: ConnectionHeaderValidationIssue,
t: ReturnType<typeof useTranslation>["t"],
): string {
if (issue.type === "missingName") return t("pairing.direct.headers.errors.missingName");
if (issue.type === "invalidName") {
return t("pairing.direct.headers.errors.invalidName", { name: issue.name });
}
if (issue.type === "invalidValue") {
return t("pairing.direct.headers.errors.invalidValue", { name: issue.name });
}
return t("pairing.direct.headers.errors.duplicateName", { name: issue.name });
}
interface ConnectionHeaderRowProps {
draft: ConnectionHeaderDraft;
index: number;
disabled: boolean;
placeholderTextColor: string;
iconColor: string;
onUpdate: (id: number, field: "name" | "value", value: string) => void;
onRemove: (id: number) => void;
}
function ConnectionHeaderRow({
draft,
index,
disabled,
placeholderTextColor,
iconColor,
onUpdate,
onRemove,
}: ConnectionHeaderRowProps) {
const { t } = useTranslation();
const handleNameChange = useCallback(
(value: string) => onUpdate(draft.id, "name", value),
[draft.id, onUpdate],
);
const handleValueChange = useCallback(
(value: string) => onUpdate(draft.id, "value", value),
[draft.id, onUpdate],
);
const handleRemove = useCallback(() => onRemove(draft.id), [draft.id, onRemove]);
return (
<View style={styles.headerRow}>
<AdaptiveTextInput
testID={`direct-header-name-${index}`}
nativeID={`direct-header-name-${draft.id}`}
accessibilityLabel={t("pairing.direct.headers.name")}
initialValue={draft.name}
value={draft.name}
onChangeText={handleNameChange}
placeholder={t("pairing.direct.headers.name")}
placeholderTextColor={placeholderTextColor}
style={[styles.input, styles.headerInput]}
autoCapitalize="none"
autoCorrect={false}
editable={!disabled}
/>
<AdaptiveTextInput
testID={`direct-header-value-${index}`}
nativeID={`direct-header-value-${draft.id}`}
accessibilityLabel={t("pairing.direct.headers.value")}
initialValue={draft.value}
value={draft.value}
onChangeText={handleValueChange}
placeholder={t("pairing.direct.headers.value")}
placeholderTextColor={placeholderTextColor}
style={[styles.input, styles.headerInput]}
autoCapitalize="none"
autoCorrect={false}
editable={!disabled}
/>
<Pressable
style={styles.iconButton}
onPress={handleRemove}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={t("pairing.direct.headers.remove")}
testID={`direct-header-remove-${index}`}
>
<X size={18} color={iconColor} />
</Pressable>
</View>
);
}
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
const parsed = parseConnectionUri(uri);
return {
@@ -305,6 +423,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const [headerDrafts, setHeaderDrafts] = useState<ConnectionHeaderDraft[]>([]);
const nextHeaderId = useRef(1);
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => {
@@ -315,6 +435,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
setHeaderDrafts([]);
nextHeaderId.current = 1;
bumpInputResetKey();
}, []);
@@ -369,11 +491,18 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
if (isSaving) return;
let connection: PreparedDirectConnection;
let headers: Record<string, string> | undefined;
try {
connection = prepareDirectConnection(
{ host, port, useTls, password },
directConnectionLabels,
);
const preparedHeaders = prepareConnectionHeaders(headerDrafts);
if (preparedHeaders.issue) {
setErrorMessage(headerIssueMessage(preparedHeaders.issue, t));
return;
}
headers = preparedHeaders.headers;
} catch (error) {
const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
@@ -389,6 +518,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
...(headers ? { headers } : {}),
});
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
@@ -425,6 +555,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
daemons,
directConnectionLabels,
handleClose,
headerDrafts,
host,
isMobile,
isSaving,
@@ -453,6 +584,22 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible((current) => !current);
}, []);
const handleAddHeader = useCallback(() => {
const id = nextHeaderId.current;
nextHeaderId.current += 1;
setHeaderDrafts((current) => [...current, { id, name: "", value: "" }]);
}, []);
const handleUpdateHeader = useCallback((id: number, field: "name" | "value", value: string) => {
setHeaderDrafts((current) =>
current.map((draft) => (draft.id === id ? { ...draft, [field]: value } : draft)),
);
}, []);
const handleRemoveHeader = useCallback((id: number) => {
setHeaderDrafts((current) => current.filter((draft) => draft.id !== id));
}, []);
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
try {
@@ -609,24 +756,58 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable>
{isAdvancedOpen ? (
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleToggleAdvanced}
/>
<View style={styles.advancedContent}>
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleToggleAdvanced}
/>
{isNative || getIsElectron() ? (
<View style={styles.headerSection}>
<View style={styles.headerTitleRow}>
<Text style={styles.label}>{t("pairing.direct.headers.title")}</Text>
<Button
variant="ghost"
size="sm"
leftIcon={Plus}
onPress={handleAddHeader}
disabled={isSaving}
testID="direct-header-add"
>
{t("pairing.direct.headers.add")}
</Button>
</View>
<View style={styles.headerList}>
{headerDrafts.map((draft, index) => (
<ConnectionHeaderRow
key={draft.id}
draft={draft}
index={index}
disabled={isSaving}
placeholderTextColor={theme.colors.foregroundMuted}
iconColor={theme.colors.foregroundMuted}
onUpdate={handleUpdateHeader}
onRemove={handleRemoveHeader}
/>
))}
</View>
</View>
) : null}
</View>
) : null}
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
+71 -27
View File
@@ -21,6 +21,9 @@ import { Archive, ChevronRight } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { HighlightedText } from "@/components/ui/highlighted-text";
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -32,6 +35,18 @@ interface AgentListProps {
listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean;
showHostColumn?: boolean;
/**
* Where a search matched each row, keyed by `serverId:agentId`. Rows mark the
* spans so the list can explain why a result is in it — the subsequence and
* typo tiers match characters the eye would not find on its own.
*/
searchMatchesByAgentKey?: Record<string, AgentSearchMatch[]>;
/**
* Renders one flat list in the given order instead of grouping by day. Day
* headings claim the list is chronological, which is a lie once the caller
* has ordered it by something else — relevance, for instance.
*/
flat?: boolean;
}
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
@@ -127,12 +142,14 @@ function SessionBadge({
function WorkspaceTitlePrefix({
visible,
workspaceName,
ranges,
testID,
iconSize,
color,
}: {
visible: boolean;
workspaceName: string;
ranges?: readonly MatchRange[];
testID: string;
iconSize: number;
color: string;
@@ -143,9 +160,13 @@ function WorkspaceTitlePrefix({
return (
<>
<Text style={styles.workspaceTitleText} numberOfLines={1} testID={testID}>
{workspaceName}
</Text>
<HighlightedText
text={workspaceName}
ranges={ranges}
style={styles.workspaceTitleText}
numberOfLines={1}
testID={testID}
/>
<ChevronRight size={iconSize} color={color} />
</>
);
@@ -203,6 +224,7 @@ function SessionRowTrailingAttention({
function SessionRow({
agent,
searchMatches,
isMobile,
selectedAgentId,
showAttentionIndicator,
@@ -211,6 +233,7 @@ function SessionRow({
onLongPress,
}: {
agent: AggregatedAgent;
searchMatches?: readonly AgentSearchMatch[];
isMobile: boolean;
selectedAgentId?: string;
showAttentionIndicator: boolean;
@@ -228,6 +251,11 @@ function SessionRow({
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
const ProviderIcon = getProviderIcon(agent.provider);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const rangesFor = useCallback(
(field: AgentSearchMatch["field"]) =>
searchMatches?.find((match) => match.field === field)?.ranges,
[searchMatches],
);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -266,6 +294,7 @@ function SessionRow({
<WorkspaceTitlePrefix
visible={!isMobile && Boolean(workspaceName)}
workspaceName={workspaceName}
ranges={rangesFor("workspace")}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
iconSize={theme.iconSize.xs}
color={theme.colors.foregroundMuted}
@@ -273,9 +302,12 @@ function SessionRow({
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || t("agentList.fallbackTitle")}
</Text>
<HighlightedText
text={agent.title || t("agentList.fallbackTitle")}
ranges={agent.title ? rangesFor("title") : undefined}
style={sessionTitleStyle}
numberOfLines={1}
/>
<SessionRowBadges
agent={agent}
archivedIcon={archivedIcon}
@@ -285,29 +317,29 @@ function SessionRow({
</View>
{isMobile ? (
<View style={styles.rowMetaRow}>
<Text
<HighlightedText
text={projectName}
ranges={rangesFor("project")}
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text>
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text
<HighlightedText
text={branch}
ranges={rangesFor("branch")}
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text
<HighlightedText
text={workspaceName}
ranges={rangesFor("workspace")}
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
>
{workspaceName}
</Text>
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{showHostColumn && agent.serverLabel ? (
@@ -323,25 +355,25 @@ function SessionRow({
</View>
{!isMobile ? (
<View style={styles.rowColumns}>
<Text
<HighlightedText
text={projectName}
ranges={rangesFor("project")}
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text>
/>
{showHostColumn ? (
<Text style={styles.columnMetaHost} numberOfLines={1}>
{agent.serverLabel}
</Text>
) : null}
<Text
<HighlightedText
text={branch}
ranges={rangesFor("branch")}
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
/>
<Text style={styles.columnMetaFixed} numberOfLines={1}>
{timeAgo}
</Text>
@@ -365,6 +397,8 @@ export function AgentList({
listFooterComponent,
showAttentionIndicator = true,
showHostColumn = false,
searchMatchesByAgentKey,
flat = false,
}: AgentListProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -432,6 +466,14 @@ export function AgentList({
}, [actionAgent, actionClient, archiveAgent]);
const flatItems = useMemo((): FlatListItem[] => {
if (flat) {
return agents.map((agent) => ({
type: "agent" as const,
key: `${agent.serverId}:${agent.id}`,
agent,
}));
}
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
for (const agent of agents) {
const section = deriveDateSectionKey(agent.lastActivityAt);
@@ -452,7 +494,7 @@ export function AgentList({
}
}
return result;
}, [agents]);
}, [agents, flat]);
const renderItem: ListRenderItem<FlatListItem> = useCallback(
({ item }) => {
@@ -466,6 +508,7 @@ export function AgentList({
return (
<SessionRow
agent={item.agent}
searchMatches={searchMatchesByAgentKey?.[item.key]}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
@@ -479,6 +522,7 @@ export function AgentList({
handleAgentLongPress,
handleAgentPress,
isMobile,
searchMatchesByAgentKey,
selectedAgentId,
showAttentionIndicator,
showHostColumn,
+2 -2
View File
@@ -36,11 +36,11 @@ const styles = StyleSheet.create((theme) => ({
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusMutedSuccess,
color: theme.colors.statusSuccess,
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusMutedDanger,
color: theme.colors.statusDanger,
},
}));
@@ -7,8 +7,8 @@ import type { Theme } from "@/styles/theme";
const ThemedSquarePlus = withUnistyles(SquarePlus);
const ThemedSquareMinus = withUnistyles(SquareMinus);
const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
const ICON_SIZE = 14;
@@ -14,6 +14,7 @@ import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
import {
markdownCopyCodeBlockDataSet,
markdownCopyDataSet,
TRAILING_CODE_LINE_BREAKS,
} from "@/assistant-selection-copy/markup";
interface HighlightedCodeBlockProps {
@@ -82,7 +83,10 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const controlsVisible = isHovered || isNative || isCompact;
const getCode = useCallback(() => code, [code]);
// Copy the code without its trailing blank lines. A fence body ends in a newline,
// and ends in more than one when the author left a blank line before the closing
// fence; pasting any of them into a terminal runs the last line.
const getCode = useCallback(() => code.replace(TRAILING_CODE_LINE_BREAKS, ""), [code]);
return (
<View
+19 -15
View File
@@ -1,31 +1,35 @@
import { useMemo } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import {
type HostRuntimeConnectionStatus,
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
function hostStatusDotColor(status: HostRuntimeConnectionStatus, theme: Theme) {
if (status === "online") return theme.colors.palette.green[400];
if (status === "connecting") return theme.colors.palette.amber[500];
return theme.colors.palette.red[500];
}
export function HostStatusDot({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const status = useHostRuntimeConnectionStatus(serverId);
const backgroundColor = hostStatusDotColor(status, theme);
const dotStyle = useMemo(() => [styles.dot, { backgroundColor }], [backgroundColor]);
return <View style={dotStyle} />;
return <View style={[styles.dot, statusStyle(status)]} />;
}
const styles = StyleSheet.create(() => ({
function statusStyle(status: HostRuntimeConnectionStatus) {
if (status === "online") return styles.dotOnline;
if (status === "connecting") return styles.dotConnecting;
return styles.dotOffline;
}
const styles = StyleSheet.create((theme) => ({
dot: {
width: 8,
height: 8,
borderRadius: 4,
borderRadius: theme.borderRadius.full,
},
dotOnline: {
backgroundColor: theme.colors.statusSuccess,
},
dotConnecting: {
backgroundColor: theme.colors.statusWarning,
},
dotOffline: {
backgroundColor: theme.colors.statusDanger,
},
}));
@@ -20,6 +20,7 @@ export interface HostFilterProps {
selectedHost: string;
onSelectHost: (serverId: string) => void;
triggerTestID?: string;
hostOptionTestID?: (serverId: string) => string;
}
/**
@@ -32,6 +33,7 @@ export function HostFilter({
selectedHost,
onSelectHost,
triggerTestID,
hostOptionTestID,
}: HostFilterProps): ReactElement {
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
@@ -64,6 +66,7 @@ export function HostFilter({
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
hostOptionTestID={hostOptionTestID}
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
@@ -266,6 +266,7 @@ export function HostPicker({
active={active}
selected={selected}
onPress={onPress}
testID={hostOptionTestID?.(option.id)}
/>
);
}
@@ -42,6 +42,23 @@ describe("resolveProviderDiscoveredModels", () => {
expect(refreshing.models).toEqual([grokModel]);
});
it("excludes compatibility-only models from display and cache", () => {
const compatibilityModel: AgentModelDefinition = {
...piModel,
id: "pi/model-legacy",
label: "Pi Model legacy",
isSelectable: false,
};
const result = resolveModels({
provider: "pi",
currentModels: [piModel, compatibilityModel],
});
expect(result.models).toEqual([piModel]);
expect(result.cache?.models).toEqual([piModel]);
});
it("does not show one provider's cached models while another provider loads", () => {
const ready = resolveModels({ provider: "pi", currentModels: [piModel] });

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