Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 1066ebb891 test(app): remove wheel listener race 2026-08-03 13:53:13 +00:00
Mohamed Boudra f821f1eca4 test(heartbeats): expand tracks idempotently 2026-08-03 13:20:43 +00:00
Mohamed Boudra b49abdc5dc test(heartbeats): start expiry after visibility 2026-08-03 13:20:42 +00:00
Mohamed Boudra 082d9c0e4a test(heartbeats): match active workspace label 2026-08-03 13:20:42 +00:00
Mohamed Boudra d84c0a8420 fix(schedules): preserve idempotent deletion 2026-08-03 13:20:42 +00:00
Mohamed Boudra 721b6a10dc fix(composer): isolate web hover handlers 2026-08-03 13:20:42 +00:00
Mohamed Boudra 40ae6447ba test(server): stub workspace activity dependencies
Give generic session schedule fixtures an explicit empty heartbeat activity result and declare both activity dependencies in the git-watch fixture. This preserves strict stubs while covering the workspace descriptor inputs introduced by heartbeat projection.
2026-08-03 13:20:42 +00:00
Mohamed Boudra c73b03a168 fix(heartbeats): gate workspace activity by host
Advertise heartbeat workspace projection as an optional daemon capability and gate the agent track at its feature boundary. Older hosts still run heartbeat prompts, but now show an update-host link instead of heartbeat rows that conflict with a done workspace.\n\nCover optional wire parsing, daemon advertisement, supported and unsupported track selection, and the update-host presentation.
2026-08-03 13:20:42 +00:00
Mohamed Boudra 20789bcd13 fix(composer): restore hover-gated track actions
Keep row actions mounted but hidden on wide web layouts until the plain row wrapper is hovered. Native and compact layouts keep controls visible, preserving the established subagent behavior in the shared track component.
2026-08-03 13:20:13 +00:00
Mohamed Boudra 7f4eb38fc0 fix(heartbeats): refresh mounted schedule state
Poll the shared schedules query while mounted and use each successful refresh timestamp when deriving heartbeat activity. This keeps externally created and expired heartbeats in sync without introducing a heartbeat-local timer.\n\nExtend the real-daemon browser flow to create heartbeats after the composer mounts and verify expiry removes the track and workspace activity.
2026-08-03 13:20:13 +00:00
Mohamed Boudra 644f5ee941 feat(agents): surface heartbeat activity
Keep idle babysitting agents visible through a dedicated heartbeat track while projecting active heartbeat schedules into workspace activity. Agent lifecycle stays unchanged so heartbeat targets do not gain interrupt semantics.
2026-08-03 13:20:13 +00:00
Mohamed Boudra ed4fc08f78 feat(website): replace the Cloud page with Paseo Hub
The Cloud page pitched a hosted multi-user product and collected emails
through a signup form. The Hub is a self-hosted service in private beta
and the only way in is a Discord DM, so the signup server function and
its webhook are deleted rather than repointed. /cloud now 301s to /hub
from the worker entry, which already owns the site's permanent path
redirects, so old links and search results keep working.

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

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

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

* fix(hub): protect daemon MCP namespace

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

* fix(agent): reject unsupported MCP launches

* fix(agent): scope MCP support guard

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

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

* fix(session): rehydrate MCP config on resume

* fix(session): resume newest matching agent record

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

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

* fix(app): preserve assistant copy semantics

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

* fix(app): preserve partial selection context

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

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

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

* fix(app): normalize partial selection context

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

* fix(app): escape copied table delimiters

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

* fix(app): preserve authored link semantics

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

* fix(app): preserve nested copy structure

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

* fix(app): preserve selected file links

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

* fix(app): preserve partial table content

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

* Address mobile terminal review feedback

* Remove dead native terminal input branch

* Replay bracketed paste in terminal preamble

* Stop per-key terminal resize claims

* Stabilize terminal resize browser test

* Use tracked terminal mode for native paste

* Restore terminal toolbar focus handling

* Adapt native terminal to active visibility state

* Stabilize native mobile terminal interactions

* Add native terminal fallback boundary

* test(app): expect explicit terminal resize reclaim

* fix(app): stabilize terminal buffer coordinates

* fix(app): restrict terminal resize ownership

* fix(terminal): gate restored input modes

* fix(app): recover stalled terminal WebViews

* chore(app): remove obsolete terminal harnesses

* fix(app): make terminal resize claims explicit

* test(app): stabilize native terminal device flows

* test(app): track absolute scrollback coordinates

* fix(app): isolate terminal WebView streams

* fix(terminal): arbitrate size ownership across clients

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

* test(server): make worker resize assertion portable

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

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

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

* clear context usage poll timer before scheduling next interval

* chore: remove .omp/ directory

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

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

---------

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

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

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

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

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

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

---------

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

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

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

* build(nix): update npm dependency hash

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

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

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

* Fix Git limiter test deadlocks

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

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

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

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

* fix(composer): preserve background lookup ownership

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

* fix(composer): preserve pasted PR selection order

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

* fix(composer): track pasted PR source order

* fix(composer): preserve resolved attachment order

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

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

* feat(app): improve sidebar workspace identity

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

* fix(app): keep host badges readable

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

* fix(app): announce selected sidebar badge mode

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

* fix(app): name hoisted workspace rows

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

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

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

* fix(app): serialize host appearance writes

* fix(app): serialize host registry writes

* fix(app): bound sidebar host badge labels

* test(app): match workspace status labels

* fix(app): consolidate sidebar identity behavior

* test(app): assert status grouping behavior

* fix(app): keep sidebar row actions accessible

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

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

* fix(app): preserve chat outline timeline coverage

* fix(app): reconcile outline window submissions

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

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

* perf(app): suspend remaining inactive queries

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

* refactor(app): separate panel activity from connectivity

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

* test(app): target active retained controls

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

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

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

* fix(e2e): preserve isolated daemon launch contract

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

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

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

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

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

* fix(server): normalize watcher runtime boundaries

* fix(server): reconcile Git observer topology

* test(server): report Git watcher event batches

* fix(server): close Git observation gaps

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

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

* fix(server): serialize workspace status updates

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

* test(server): update workspace status fixtures

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

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

---------

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

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

* fix(acp): validate selected permission behavior

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

* fix(acp): preserve invalid permission requests

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

* test(acp): verify real permission questions

* fix(acp): reject empty permission action ids

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

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

* fix(server): keep idle provider exits recoverable

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

* fix(server): preserve provider exit recovery

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

* test(server): make process exit harness portable

* fix(server): clear permissions after provider exit

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

* fix(server): serialize provider reconnects

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

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

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

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

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

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

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

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

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

* refactor(ci): align tests with runtime domains

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

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

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

* fix(ci): isolate desktop e2e runtime

* perf(test): isolate Playwright workers

* perf(test): parallelize Playwright within specs

* fix(test): keep Playwright file scheduling

* fix(test): share dynamic Metro fixture

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

* fix(ci): preserve routed contracts after rebase

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

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

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

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

* Add PR review gallery images for the status badge PR

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Add propagation recording showing statuses resolving to the project badge

* Add higher-quality mp4 of the status propagation recording

* Refactor project status badge coverage

---------

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

* Build plus artifacts for desktop and CLI/server

* Document and summarize plus artifact workflow

* Document Apple signing and pin plus workflow actions

* Disable Linux plus desktop artifact

* Publish plus CLI packages to GitHub Packages

* Fix GitHub Packages publish paths

* Fix plus mise install summary package name

* Disable macOS x64 plus desktop artifact

* Tag plus npm packages as latest for mise

* fix(worktrees): respect selected local base ref

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

* fix(worktrees): preserve exact branch picker refs

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

* Lock timeline layout stability with user-driven specs

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

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

* Measure the turn footer blink from when the footer appears

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

* Rename the shadowed prompt row binding in the frame recorder

* fix(desktop): allow concurrent worktree dev instances

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

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

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

* fix(timeline): stabilize optimistic turn layout

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

* fix(timeline): preserve optimistic prompts through hydration

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

* fix(timeline): make submitted prompts canonical

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

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

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

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

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

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

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

* test(timeline): cover running state across refresh

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

* fix(timeline): keep history loading geometry stable

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

* fix(timeline): keep turn activity coherent

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

* fix(tests): align checks with runtime boundaries

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

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

* fix(timeline): preserve activity through turn handoff

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

* refactor(timeline): remove submission timing state

* fix(timeline): restore prompt identity on resume

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

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

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

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

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

* fix(timeline): load history after keyboard scrolling

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

* test(server): preserve accepted turn stream order

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

* test(app): scope sidebar fixtures by identity

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

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

* fix(timeline): finalize canonical prompt delivery

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

* fix(server): preserve legacy prompt pagination

* fix(timeline): keep assistant block identity stable

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

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

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

* fix(server): preserve queued agent transitions

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

* fix(server): snapshot queued agent updates

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

* docs(app): date agent turn compatibility cleanup

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

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

* refactor(app): remove prompt revision reconciliation

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

* test(app): cover submitted prompt rewind roundtrip

* docs(timeline): describe immutable submitted prompt identity

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

* test(server): validate mock rewind targets

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

* fix(server): validate rewind before interrupting

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

* test(app): guard rewind eligibility and rejection

* fix(server): preserve prompt identity across hydration

* refactor(timeline): remove stale revision remnants

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

This reverts commit 6adcf7e5b0.

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

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

* test(e2e): clean partial archive scenarios

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

* test(workspaces): pin archive latch restoration

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

* fix(workspaces): latch restored legacy archives

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

* fix(workspaces): refresh latch on restore

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

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

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

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

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

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

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

* fix(app): preserve focus mode outside workspaces

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

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

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

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

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

* fix(relay): keep pairing state authoritative

* fix(relay): refresh pairing state across transports

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

* fix(relay): surface transport startup failures

* fix(pairing): gate offers for older daemons

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

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

* fix(relay): synchronize config during daemon startup

* fix(relay): preserve mutability for ignored overrides

* fix(cli): use reachable daemon for pairing

* fix(app): redirect invalid pair device routes

* fix(pairing): support remote host device pairing

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

* fix(pairing): validate pairing destinations

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

* fix(pairing): render QR codes across platforms

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

* fix(cli): pair from persisted relay state

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

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

* fix(app): restore iOS sidebar close swipes

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

* fix(app): recover sidebar drags after cancellation

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

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

* refactor(projects): simplify appearance handling

* fix(projects): serialize appearance saves

* test(app): update project appearance expectations

* test: fix project appearance CI expectations

* refactor(projects): organize appearance code

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

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

* fix(desktop): restore Linux development startup

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

* feat(projects): add custom project icons

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

* refactor(settings): scope projects by host

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Providers that report no task keep the previous behavior.

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

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

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

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

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

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

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

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

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

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

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

* feat(claude): read subagent effort from hooks

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

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

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

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

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

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

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

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

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

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

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

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

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

* Generalize provider subagent presentation

---------

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

* fix(workspaces): trust hydrated project capability

* fix(workspaces): resolve branch before creation

* fix(workspaces): honor invalidated checkout status

* fix(workspaces): preserve isolation while projects load

* fix(workspaces): keep provisional project routes usable

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

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

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

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

* Unify Command Center agent controls

* refactor: clarify agent control ownership

* refactor(app): tighten command center agent controls

---------

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

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

* fix(git): preserve enterprise fork identity

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

* fix(git): honor local branch upstreams

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

* fix(git): own change request branch bindings

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

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

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

* fix(git): trust current enterprise fork identity

Explicit tracking now derives repository identity from the current remote path, so stale checkout metadata cannot override a repointed fork.

* fix(git): recognize enterprise repository aliases

GitHub repository identity is case-insensitive. Normalize equality checks so differently cased aliases cannot turn base tracking into a fork lookup.

* fix(git): reject ownerless legacy fork matches

Owner-qualified legacy hints only belong to their historical owner-prefixed branch names. A later ownerless branch reusing the same head must resolve from current Git state instead.

* fix(git): keep fork feature lookups local

A feature branch may track the upstream base branch for ancestry even when origin is a fork. Only treat that cross-repository base ref as PR identity when the local branch name identifies the tracked head.
2026-07-31 23:06:10 +08:00
Mohamed Boudra baa1c08ec8 Update SKILL.md by removing models section
Removed models section from SKILL.md and added clarification on provider resolution.
2026-07-31 21:56:11 +08:00
Mohamed Boudra 99eca19a76 Keep selected projects when switching hosts (#2700)
* fix(app): preserve project selection across hosts

Rendered project identity and persisted equivalence were conflated after duplicate-project handling. Keep them separate so host changes resolve selectable equivalents deterministically without resetting legacy sidebar identity.

* fix(app): restore selected clone after host round trip

Keep manual origin and current destination placements separately so switching back restores the chosen checkout while archive gaps preserve the active host selection.

* fix(app): key status icons by project view

* fix(app): preserve origin during archive gaps
2026-07-31 15:42:08 +02:00
nikuscsandMohamed Boudra 87ef631ac4 Show project icons in status-grouped sidebar rows (#2416)
* feat: show project icons in sidebar status view

* test(sidebar): keep status icon regression active

The ten-second mock can finish during app startup, removing the Working bucket before the icon assertion. Keep the seeded workspace active for the full scenario.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-31 14:44:31 +02:00
Mohamed BoudraandDes Flynn d9b72e1c76 Use OpenCode busy status for background activity (#2696)
* fix(server): ignore OpenCode snapshot diffs when starting autonomous turns

OpenCode stamps its filesystem-snapshot diff onto the user message after
the turn goes idle. Since #2258 Paseo treats an unknown idle user
message.updated as the start of an autonomous turn, so that late metadata
write opened a turn no provider-idle boundary would ever close. The agent
stayed falsely running until the next submission interrupted it.

Reject user message.updated events carrying summary.diffs. Real prompts
never carry snapshot diffs, so genuine autonomous turns still start, and
filesystem snapshots stay enabled for undo and revert.

* fix(server): trust OpenCode busy status for autonomous activity

Mutable message records can receive metadata after execution ends. Treat the provider's busy status as the activity boundary and buffer preceding content until that boundary arrives.

* test(server): cover autonomous provider activity

* fix(server): reconcile OpenCode wakes from busy status

# Conflicts:
#	packages/server/src/server/agent/providers/opencode-agent.ts

* fix(server): preserve background activity boundary after stops

* fix(server): keep idle events outside background activity

Only provider execution status proves a new activity boundary. Reconcile adopted sessions once at subscription time instead of retaining unowned idle events for a future run.

* fix(server): order adopted session reconciliation

Live runner-status events supersede an in-flight status snapshot. Treat busy and retry as one active provider state.

* test(server): remove misleading snapshot gate

---------

Co-authored-by: Des Flynn <des@desflynn.com>
2026-07-31 13:58:08 +02:00
Mohamed Boudra 8e983c5d27 Stop transient file conflict callouts (#2694)
* fix(app): prevent transient file conflict callouts

Watcher versions are candidates until a matching disk read settles them. The editor now classifies the settled bytes once, preserving dirty buffers and keeping reload content paired with its revision.

* fix(app): preserve external BOM changes
2026-07-31 19:03:11 +08:00
Mohamed BoudraandClaude Opus 5 91da0bac4d Choose which orchestration skills get installed (#2680)
* feat(settings): choose which orchestration skills to install

The skills row installed the whole bundle or nothing. A settings control
next to it now opens a sheet where you keep every bundled skill or pick a
subset, and saving converges ~/.agents, ~/.claude, and ~/.codex to it.

"All skills" reads the bundle directory instead of a hardcoded catalog, so
a skill added in a later release installs itself without another code
change. Retired bundle names stay in a separate cleanup list so old copies
on disk are still removed but never become selectable.

The selection persists under Electron user data, separate from install
state, so uninstalling does not erase the choice — a later install brings
back exactly the chosen set. Installs with no saved selection read as
"all", which is what they already had.

* Restore skill selection after failed save

* fix(settings): don't save a skill selection that never reached disk

A failed save wrote the preference, converged, then wrote the old preference
back. That leaves a window where a selection the user was told did not save
is the one on disk, and a crash inside it makes the window permanent.
Converge first and persist only on success — nothing to roll back, and a
failed save now leaves disk and preference exactly as Cancel would.

Resolving the bundle path when the command handlers are wired also broke
daemon-manager's tests: the packaged branch reads process.resourcesPath,
which is undefined outside Electron. Resolve targets per command instead —
registering handlers should not touch the environment.

* fix(settings): make skills convergence single-writer and confirm removals

Startup auto-update and the settings commands were separate writers over the
same skill directories. Either could apply a plan computed from a selection the
other had already replaced — narrow the selection while startup is repairing
drift and the repair puts the removed skill back. There is now one controller
that owns scan, apply, and persist, and every entry point queues through it, so
concurrent callers see a consistent result instead of an interleaved one.

Save also had no recovery once it had converged: if the selection could not be
committed afterwards, disk was changed while the preference still said the old
thing, and the next startup silently undid the change. Save now puts the
managed directories back to what the committed selection describes before it
reports the failure, so a failed Save lands where Cancel would.

Deselecting a skill deletes its directory in all three agent homes, including
files the user put inside it. That is now confirmed like every other
destructive action, naming the skills and the directories. Adding skills or
saving an unchanged selection still saves without a prompt, and a skill the
host has not installed yet deletes nothing so it does not prompt either.

* fix(settings): confirm removal for skills installed in only some agent homes

The removal confirmation read an `add` op as "not on disk yet". An `add` op
means "missing from at least one target", so a skill sitting in ~/.claude but
not the other two looked absent, and deselecting it deleted that directory —
with anything the user had put inside it — without asking.

The status snapshot now carries the installed skill names directly, taken from
union presence across the three agent homes, and the confirmation reads that.
`ops` keeps its one meaning.

A host that predates the field cannot say what is on disk, so the app bridge
assumes the saved selection is installed. Asking about a directory that turns
out to be absent is harmless; the reverse is not.

* fix(settings): warn about every skill directory a save deletes

The confirmation asked "was this selected before and is it selected now",
which is a different question from the one the host answers. The host
converges to the desired set and deletes every managed directory outside it,
so a skill on disk that the saved preference never mentioned — reinstalled by
hand, restored by a sync, left behind by drift — was deleted with no warning,
user files included.

The predicate now mirrors the host rule: everything installed that the draft
will not keep, regardless of what was saved before. It moved out of the sheet
into its own module so the rule has one owner and can be tested directly.
Saving with nothing to delete still does not prompt.

* fix(settings): a failed save keeps files the user added inside a skill

Save converges before it commits, and convergence deletes whole skill
directories. When the commit then failed, the recovery re-installed the
committed selection — which rebuilds the bundled files and nothing else, so
notes, hooks, and anything else the user had put inside a deselected skill
were gone for good. Cancel does not do that, and a failed save is supposed to
land where Cancel would.

Save now runs as a transaction. Before converging it stages a copy of every
managed skill directory across the three agent homes, next to the tree so the
copy stays on one filesystem. Committing drops the staging; failing anywhere
puts each directory back exactly as it was, deletes the ones the candidate had
created, and then drops the staging. Staging left by a process that died
mid-save is cleaned up by the next one.

The controller only sees begin/commit/rollback; the filesystem work lives in
skills-transaction.ts.

* fix(settings): make skill removal confirmation authoritative

* fix(settings): verify skill convergence before commit

* fix(settings): preserve concurrent skill edits on rollback

* fix(settings): preserve staged skill metadata

* fix(settings): stage skill deletions atomically

* fix(settings): stage removals per filesystem

* fix(settings): restore staged skill symlinks

* fix(settings): canonicalize skill transaction roots

* fix(settings): keep automatic skill updates non-destructive

* fix(settings): report committed skill saves accurately

* fix(settings): keep skill updates non-destructive

* fix(settings): route skill removals through selection

* fix(settings): close skill selection edge cases

* fix(settings): preserve skill data during maintenance

* fix(settings): hide unavailable skill install

* fix(desktop): keep install policy at command boundary

* fix(desktop): restore staged skill directories atomically

* test(app): retry transient skills sandbox cleanup

* fix(desktop): recover externally deleted skill stages

* fix(desktop): preserve conflicting skill rollback data

* fix(desktop): generalize skill rollback conflicts

* fix(desktop): preserve skill rollback collisions

* fix(desktop): recover quarantined skill backups

* fix(app): inset adaptive sheet content on compact

The compact Choose skills sheet rendered edge to edge: the All skills card
touched both screen edges, the section label was glued to it, and the skill
list ran to the edges too. Desktop was correct.

AdaptiveModalSheet expressed its content inset through
BottomSheetScrollView.contentContainerStyle. That is a library prop, not the
style prop Unistyles registers, and gorhom's scroller was never rewritten by
the Babel plugin, so on web the emitted class was never applied and the
padding and gap resolved to nothing. The desktop card uses a real ScrollView,
which Unistyles does track, which is why only compact was affected.

The inset now lives on a View the sheet owns, rendered by every presentation,
so one definition covers compact and desktop and a scroller only ever gets
theme-free layout. The caller-facing prop is contentStyle, since it no longer
lands on a scroll content container.

Choose skills drops its ad-hoc label for SettingsSection, so the label carries
the app's section rhythm instead of sitting equidistant between two cards.

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

* fix(desktop): prevent skill rollback data loss

Preserve externally replaced directory trees during recovery and reject managed child symlinks before synchronization can write outside a skill directory.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:28:32 +02:00
Mohamed Boudra f3fee73976 Stop OpenCode cancels from killing the next turn (#2662)
* fix(opencode): prevent late aborts from canceling replacement turns

OpenCode aborts are session-scoped, so provider idle alone does not make a session safe to reuse. Keep abort retries owned by the stopping turn and require both abort settlement and provider idle before dispatching the next runner operation.

* fix(opencode): retain the stop barrier through provider idle

Keep the stopping operation authoritative until both its session-scoped abort and provider-idle evidence settle, including stale idle-session interrupts. Also keep the real drift fixture credential-allowlisted and wait for command output pipes to drain.

* test(opencode): launch Windows fixture shims safely

* fix(opencode): recover stop observation safely

* fix(opencode): preserve autonomous wake through stop

* fix(opencode): retain deferred wake completion

* fix(opencode): serialize stop boundary replay

* test(opencode): use shared real e2e lane

* fix(opencode): own stops started during replay

* fix(opencode): retry stop observation on demand

* fix(opencode): follow replacement stop boundaries

* fix(opencode): retain wakes during stop recovery

* fix(opencode): hold replay turns behind boundary

* fix(opencode): filter canceled reconnect output

* fix(opencode): bind status evidence to stop

* fix(opencode): acknowledge owned cancellation

* fix(opencode): reconcile stale busy evidence

* fix(opencode): bind replay terminals to wakes

* refactor(opencode): own the stop boundary in one explicit state

The run boundary had grown into a mutable flag bag plus a raw-event
buffer that was replayed and re-classified after the fact. Each new
interleaving needed another predicate, and the classification kept
losing autonomous wakes.

Restore stopping as a turn-state variant that owns exactly two things:
the session-scoped abort it issued (with its single owned retry) and the
canceled run's terminal. Runner operations wait on both; a failed abort
fails closed. Provider status reconciliation now ends the stop through
the same transition an SSE terminal takes.

The canceled run's residue is suppressed only up to that terminal.
Events published afterwards are a new run by construction and take the
normal live path, so a wake no longer waits for abort settlement and
cannot be dropped by a retention heuristic.

* fix(opencode): emit cancellation before adopting the next run

The acknowledgement ran off a promise combinator, so a wake adopted from
the event right after the terminal could reach subscribers before the
canceled turn's turn_canceled. Acknowledge synchronously from whichever
proof lands first instead.

* docs(opencode): attach the stop doc comment to its interface

* fix(opencode): retry the stop in progress instead of opening a second

Pressing Stop again after an abort failed built a detached boundary: its
fresh abort could succeed while the canceled turn kept waiting for an
acknowledgement, so the daemon sat out the full cancel timeout before
force-canceling. A repeated Stop now replaces the abort owned by the
stop already in progress, which is what carries the acknowledgement and
what lifts the fail-closed gate.

* fix(opencode): accumulate every abort a stop has issued

A repeated Stop replaced the stop's abort settlement instead of adding
to it, so a first request still in flight — the slow-teardown case the
cancel cap exists for — no longer held the gate. The replacement could
start while that request was live and get cancelled by it, which is the
race this boundary exists to close. Reuse now waits for every abort the
stop issued, while only the newest can hold the gate closed.

* fix(opencode): scope abort settlement to the session, not the stop

Abort is session-scoped, so its settlement has to be too. Tracking it on
the stop meant a new stop boundary silently dropped an older request
that was still in flight, and that request could still cancel whatever
run started next. The session now accumulates every abort it has issued;
the stop is left owning only the run it is stopping.

* fix(opencode): recheck the abort gate after waiting on it

A waiting prompt read the settlement once, so a Stop issued while it
waited was invisible to it: the older abort would settle, the prompt
would start, and the newer one could still cancel it. Drain until the
settlement observed is the current one.
2026-07-31 12:08:43 +02:00
Mohamed Boudra eb5e8ba7c5 Revise PR template to highlight CONTRIBUTING.md
Updated the PR template to emphasize reading the CONTRIBUTING.md file.
2026-07-31 16:40:49 +08:00
Mohamed Boudra fe337b888d Update pull request template for type of change options 2026-07-31 16:40:18 +08:00
Mohamed Boudra 774b417cc5 Revise PR template for clarity and structure
Updated the pull request template to clarify guidelines and checklist items.
2026-07-31 16:39:41 +08:00
Mohamed Boudra b6f1274f47 Clarify changed and deleted files in the file viewer (#2670)
* fix(app): distinguish changed and deleted files

Successful reads now reconcile transient missing events without letting stale
cached data mask a deletion. File notices expose only actions valid for the
current buffer state.

* fix(app): preserve file state ordering

* fix(app): allow retrying file checks

* fix(app): clear unchanged file conflicts

* fix(app): keep file conflict recovery consistent

Own disk observation and read ordering in one model so stale reads and failed subscriptions cannot produce contradictory actions.

* fix(app): allow retrying file preview errors

Preserve daemon read errors and expose retry for initial and non-editable file loads.
2026-07-30 22:50:58 +02:00
Mohamed Boudra 240d7f366d Clarify discussion links for feature requests
Updated the 'about' sections for Discord and GitHub discussion links to clarify that they include feature requests.
2026-07-31 02:19:25 +08:00
Mohamed Boudra 710851ccb8 docs(contributing): allow draft PRs for checks and early feedback 2026-07-30 16:31:51 +02:00
Mohamed Boudra b18ad74ac6 docs: simplify contributing guide 2026-07-30 15:58:08 +02:00
Mohamed Boudra 588fdc794b docs: rewrite contributing guide, add QA and compatibility docs
Issues are bug-only now. The feature-request template is gone, requests
opened as issues get closed automatically, and feature talk moves to
product discussions on GitHub Discussions and Discord. "Product
discussion" is the term everywhere, recorded in the glossary.

Contributing states the QA evidence bar and links out instead of
teaching; docs/qa.md carries the depth. The protocol back-compat rules
moved out of CLAUDE.md into docs/protocol-compatibility.md, since that
constraint is invisible in development where both sides always run the
same version.

CLAUDE.md gains doc-writing and voice rules to keep docs from
accumulating appended paragraphs. design.md gains an alignment section
(glyph rails) and a layout-shift rule.
2026-07-30 15:52:50 +02:00
paseo-ai[bot] 4e6f759da9 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-30 11:31:41 +00:00
Mohamed Boudra 6fc491e622 chore(release): cut 0.2.5 2026-07-30 13:22:40 +02:00
Mohamed Boudra 95257a6353 docs(changelog): simplify 0.2.5 entry 2026-07-30 13:17:51 +02:00
Mohamed Boudra 4d7d6a7d1b docs(changelog): 0.2.5 2026-07-30 12:33:47 +02:00
Cafe Babeandtang b345e53a9b Fix Linux desktop package startup (#2654)
Restore the Linux startup behavior that shipped in 0.2.3 by reverting the generalized sandbox detection from #2439.

Co-authored-by: tang <tang@tang-zai>
2026-07-30 12:32:44 +02:00
paseo-ai[bot] d1ce2b77f7 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-29 23:58:09 +00:00
Mohamed Boudra 2f913736fa chore(release): cut 0.2.4 2026-07-30 01:48:57 +02:00
Mohamed Boudra 8f8a373eea docs(changelog): 0.2.4 2026-07-30 01:45:18 +02:00
Mohamed Boudra c6df5ffd4d Revert "Carry local files and shared state into new worktrees (#2419)"
This reverts commit cbbf6c1684.
2026-07-30 00:37:52 +02:00
Mohamed Boudra 20e46e3e3f Restore project grouping across hosts (#2565)
* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* fix(projects): keep ambiguous clones separate

A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
2026-07-29 23:36:31 +02:00
nikuscsandMohamed Boudra 9e5accee3c fix: reject oversized checkout diffs (#2488)
* fix: reject oversized checkout diffs

* fix(diff): keep large previews from disconnecting clients

The physical socket limit is an OOM backstop, not a frame-size contract. Bound structured diff construction incrementally and report oversized previews without closing the client's socket.

* fix(app): preserve oversized diff state

* fix(diff): stay within relay frame limit

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-30 00:48:05 +08:00
Mohamed Boudra 16bbead5a4 Stop Windows server CI from flaking on stderr timeouts (#2613)
* test(server): make stderr timeout coverage deterministic

The timeout assertion raced Windows child-process scheduling and stderr pipe delivery. Buffer stderr before starting the timed request so the test isolates the transport behavior it intends to verify.

* test(server): synchronize stderr timeout test in parent

Use the injected process adapter to populate stderr through the parent-side stream before starting the timeout. This removes cross-pipe ordering from the test.
2026-07-29 18:11:47 +02:00
ee3a9b013b Support repository search on older GitHub CLI versions (#2611)
* fix(github): support repository lists from older gh versions

GitHub CLI versions before 2.28 reject the visibility JSON field before returning repository data. Retry that specific failure with the older isPrivate field while preserving exact visibility on newer clients.

* fix(github): stop requesting repository visibility

Repository cloning does not use visibility. Leave the released response field optional and keep repository discovery on JSON fields supported by older GitHub CLI versions.

* fix(github): keep the visibility enum on the wire, derive it from isPrivate

Restores the required visibility enum in the search-repositories response
schema. Omitting it broke old clients, which reject the whole response.

The daemon still never asks gh for visibility -- that JSON field does not
exist before gh 2.28 and Debian Bookworm ships 2.23. It asks for isPrivate,
supported on every version, and maps it to public/private.

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

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:05:59 +02:00
Mohamed Boudra 891dd5a813 feat(providers): expose agent cwd to provider processes (#2563) 2026-07-29 16:42:42 +02:00
Mohamed Boudra 68a215d40c Prevent restored file trees from crashing desktop startup (#2595)
* fix(explorer): prevent persisted trees from crashing startup

Persisted expansion restored every directory and recursively flattened the resulting tree during render. Limit automatic restoration to five rendered directory levels and flatten iteratively so deep or broad workspaces cannot exhaust the JavaScript stack.

* fix(explorer): preserve expansion changes during restore

Reconcile completed restoration against the current expansion set atomically so folder interactions made while directory requests are in flight are not overwritten.

* fix(explorer): show hidden files before restoration

* fix(explorer): apply directory toggles atomically
2026-07-29 22:10:51 +08:00
Slava Goltserandpaseo-bot[bot] b897c59e9c fix: preserve context window data when turn_completed lacks usage (#2494)
* fix: preserve context window data when turn_completed lacks usage

Merge incoming usage into agent.lastUsage instead of replacing it
wholesale. Some providers (Claude autonomous turns, OpenCode) emit
turn_completed without a usage payload, which previously wiped the
contextWindowMaxTokens/contextWindowUsedTokens accumulated during
streaming usage_updated events. The result was the context window
meter disappearing from the composer once the agent finished work.

* test(server): preserve usage after autonomous completion

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-07-29 22:10:18 +08:00
Mohamed Boudra acb8506d1f Revert the timeline optimistic and pagination rework (#2596)
* Add npm release track invariant and beta dist-tag procedure

* Revert "Keep older chat history and image previews stable (#2490)"

The timeline optimistic/pagination rework needs more work than main releases can wait for. Reverted together with #2484; both are reapplied on integration/timeline-optimistic-rework.

* Revert "Stop completed turns from appearing stuck (#2484)"

See the #2490 revert. #2490 built on this submission-authority change, so the two revert and reapply as a pair.
2026-07-29 13:35:43 +02:00
Matt Cowger ab24070075 fix(server): suppress Pi interruption stream error (#2311)
* Handle Pi aborted responses after interruption

* fix(server): suppress late Pi interruption terminal response
2026-07-29 18:41:09 +08:00
Mohamed Boudra fab975a059 Keep idle agents and their background work alive (#2590)
* fix(server): keep idle agents resident

Remove time-based runtime collection so background work and subsequent prompts are not destroyed during idle periods. Runtimes now close only through explicit lifecycle actions.

* fix(server): preserve explicit close coordination

* fix(server): support partial agent manager adapters
2026-07-29 18:36:44 +08:00
Mohamed Boudra 504b687f89 Keep older chat history and image previews stable (#2490)
* fix(app): keep timeline history and previews stable

Treat persisted timeline replicas as display-only so authoritative pagination always comes from the daemon. Require renewed user intent between older-history pages unless the viewport genuinely remains at the history edge.

Model assistant image acquisition as loading, loaded, or failed so recreating a preview URL cannot be rendered as an error.

* fix(app): keep file image previews current

* fix(app): stabilize history pagination edges

* fix(app): recover transient timeline loads

* fix(app): retain assistant image previews

* Stabilize history pagination lifecycle

* Stabilize history settlement and image previews

* Retry file images after reconnect

* Keep active previews and pagination requests alive

* Replace image hook tests with typed ports

* Integrate timeline hydration with submission authority

* Harden pre-hydration timeline reconciliation

* Make rewind E2E use real scroll intent

* Preserve live timeline state through hydration

* Keep mounted image attachments retained

* Protect preview persistence and lifecycle hydration

* Preserve idless live assistant continuations

* Preserve timeline rows across delayed hydration

* test(app): cover real assistant image files

* Preserve assistant tool ordering during hydration
2026-07-28 22:29:03 +02:00
Mohamed Boudra bbf3d0f9cc Keep plan approval focused on the latest proposal (#2534)
* fix(server): replace stale plan approvals

Synthetic plan approvals accumulated across planning turns because each proposal used a new permission ID. Dismiss the current proposal only after a follow-up prompt is accepted, and enforce one pending proposal when a newer plan arrives.

* fix(server): close plan approval submission race

Deactivate the existing proposal before starting a revision so it cannot be implemented concurrently. Restore it when submission fails, while preserving any newer approval emitted during the request.

* fix(server): deactivate plans before prompt setup

Start the plan-dismissal transaction before any asynchronous prompt setup so a stale proposal cannot be implemented from another client while the revision is preparing.

* fix(server): make prompt dismissal final

Treat prompt submission as the dismissal event instead of compensating after failures. This avoids resurrecting stale plans across ambiguous provider outcomes and manager failure cleanup.
2026-07-28 17:10:32 +02:00
Mohamed Boudra 963d4f9240 Configure agent thinking from the CLI (#2533)
* feat(cli): update agent thinking from the CLI

* fix(cli): harden agent thinking updates

* feat(cli): configure thinking for schedules

* fix(cli): report applied thinking updates

* fix(cli): report current agent thinking state
2026-07-28 22:30:50 +08:00
nllptrxandMohamed Boudra e241e02afb feat(app): dismiss the chat keyboard on a fast upward flick (#2417)
* feat(app): dismiss the chat keyboard on a fast upward flick

Scrolling the history to read earlier messages left the keyboard up, so the
visible transcript stayed cramped and the keyboard had to be closed by hand
first — two steps for what should be one.

React Native's keyboardDismissMode cannot express the wanted behaviour:
"on-drag" fires on the first pixel and kills the keyboard on any peek-scroll,
and "interactive" is broken on inverted lists. So the gesture is measured
here: samples are taken from the scroll events and, at release, the speed over
the drag's final stretch decides. Measuring at release rather than averaging
the whole drag is what keeps a fast but controlled read-scroll from counting
as a flick, since such a gesture decelerates before the finger lifts.

Timestamps and offsets come from the events, never from a clock: with a busy
JS thread the callbacks arrive in a burst long after the gesture, and
wall-clock spacing then reads a calm scroll as a flick. The release offset
comes from the end-drag event for the same reason — the last onScroll can be
stale by the time a short flick lands.

Android needs both the blur and the dismiss. Dismissing alone leaves the
input focused and the keyboard inset applied, so the layout stays shifted
with an empty gap where the keyboard was; blurring alone releases focus but
leaves the IME on screen.

Verified on an Android device with a Release build: a slow drag and a
0.6 dp/ms scroll keep the keyboard, a 2.9 dp/ms flick dismisses it and the
composer settles back with no leftover gap. iOS was exercised by hand on
device only, without automated coverage of the gesture itself.

* refactor(app): isolate keyboard flick dismissal

* fix(app): isolate keyboard shift context

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 22:29:33 +08:00
Mohamed Boudra fdee3236f7 Speed up server CI tests (#2537)
* perf(ci): reduce server test latency

Server test files use isolated resources, so they do not require suite-wide serialization.

* fix(ci): scope server test parallelism to unit suite

Keep real-provider and local-resource suites serialized because they may share user configuration and account limits.

* fix(terminal): isolate zsh runtimes by process

Prevent concurrent daemon and test processes from deleting or replacing shell integration files used by another process.

* fix(ci): preserve required matrix check names

GitHub evaluates job conditions before expanding a matrix. Expand active matrices first and gate their expensive steps so required check contexts are always reported. Allow superseded runs to cancel while retaining fail-open path detection.
2026-07-28 21:51:12 +08:00
stonegray fd7061a8b2 Fix AppImage launches from Linux desktops (#2439) 2026-07-28 13:59:26 +02:00
Mohamed Boudra 76e336a1be Run only relevant CI checks for each pull request (#2500)
* perf(ci): skip unaffected test jobs

Keep required checks present as skipped jobs and run the full matrix whenever change detection cannot produce a trustworthy result.

* fix(ci): harden change-based job gating

Include shared build inputs in packaged desktop smoke selection and pin the path filter action that controls job execution.

* fix(ci): run CLI checks for Nix packaging changes

The CLI supervision regression suite reads the Nix package definition directly, so include that external dependency in its path filter.

* fix(ci): track external test inputs

Select server and CLI suites when their narrowly scoped cross-package fixtures and source assertions change.

* fix(ci): track server test CLI imports

Run server tests for CLI source changes because the Hub relationship harness executes the CLI command graph directly.

* fix(ci): pin gating checkout action

Keep every third-party action that controls change detection pinned to a verified commit SHA.
2026-07-28 19:18:56 +08:00
Jason@HND f0d7eeb98c fix(quota): restore Grok Settings usage for current CLI auth/billing (#2353)
* fix(quota): restore Grok Settings usage for current CLI auth/billing

Grok CLI no longer stores a top-level access_token or usage.creditUsage.
Read nested auth key tokens and config.used.val so Settings → Usage
shows monthly credits again. Keep legacy shapes and env tokens working.

Fixes #2352

* fix(quota): make Grok auth-file tests work on Windows

Inject homeDir into GrokQuotaProvider like Kimi so nested
~/.grok/auth.json tests do not depend on os.homedir() which
ignores $HOME on Windows (USERPROFILE).
2026-07-28 18:49:21 +08:00
Mohamed Boudra f91a984348 fix(claude): show a single 1M-context Opus 5 model (#2497) 2026-07-28 18:18:21 +08:00
Michael WuandMohamed Boudra cbbf6c1684 Carry local files and shared state into new worktrees (#2419)
* feat(server): support symlink worktree includes

* refactor(server): simplify worktree include handling

* fix(server): constrain worktree include traversal

* fix(server): clean up failed worktree branches

* fix(server): skip missing worktree include entries

* fix(server): make worktree includes best effort

* fix(server): skip failed worktree includes

* fix(server): harden worktree include planning

* fix(server): preserve reused worktree result shape

Materialization reports describe one creation attempt, not durable worktree identity. Carry them beside the worktree so newly created and reused results keep the same stable shape.

* fix(server): harden worktree include boundaries

Replace directory snapshots exactly, keep canonical Git metadata protected, report recovery skips, and only roll back branches owned before worktree creation.

* fix(server): follow safe include aliases

Traverse canonical in-checkout directory links during glob planning and treat coded revalidation failures as per-entry skips.

* fix(server): make include preflight race safe

Create fetched checkout refs atomically, retain overlapping copy entries for independent fallback, and protect the full managed-worktree base.

* fix(server): preserve staged recovery state

Validate completed directory snapshots, retain backups after failed restoration, and derive atomic ref guards from the repository object ID width.

* fix(server): preserve partial include progress

Keep safe glob matches, enforce recursive-directory types, validate staged roots, and retain OID guards through rollback.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 03:18:40 +02:00
KamilandMohamed Boudra 89c2fac3c6 Open project and workspace folders from the sidebar (#2491)
* feat(app): open project folder from project context menu (#2487)

* refactor(app): give file manager action a home

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 00:48:29 +02:00
Saravjeet 'Aman' SinghandMohamed Boudra f8dd0fc2e0 Switch projects from New Workspace with ⌘P/Ctrl+P (#2110)
* feat(app): add ⌘P shortcut to switch project on New Workspace screen

Opens the existing project picker with its search focused so the project
can be switched from the keyboard (type + Enter) instead of clicking the
badge and then the project.

Wires a new "workspace.project.pick" action through the standard keyboard
pipeline (binding -> route passthrough -> dispatcher), handled by a
screen-scoped handler on the New Workspace screen that is only registered
while the screen is mounted and there are projects to pick. Because
preventDefault only fires when a handler handles the key, ⌘P/Ctrl+P still
triggers native print everywhere else. Adds a Settings -> Shortcuts help
row (rebindable) and the "Switch project" label across all locales.

* test(app): cover project picker shortcut

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 00:32:41 +02:00
Dmitry Sinev b55acfc60f fix(omp): accept nullable model context windows (#2406) 2026-07-28 00:28:30 +02:00
黄黄汪 a6fdcb469c docs: list paseo-skins as a community project (#2343) 2026-07-28 00:08:25 +02:00
Derek Perez 5bd317205c fix(omp): expose injected Paseo tools directly (#2418) 2026-07-27 23:44:25 +02:00
Matt Cowger e59c94812b perf(build): parallelize server dependencies (#2434) 2026-07-28 04:12:39 +08:00
维她命@ 1c8fabd293 fix(server): discover Codex project skills from cwd (#2423) 2026-07-27 22:12:22 +02:00
Matt Cowger 717f195f0c fix(app): render HTML in PR comments (#2432) 2026-07-28 04:01:43 +08:00
Li Mu ZhiandClaude Opus 4.8 869edcbf11 fix(forge): preserve non-default port in forge web URLs (#2478)
"Open in browser" links for a self-hosted forge served on a non-standard
port (e.g. Forgejo/Gitea on :60443) dropped the port, producing
https://host/owner/repo/... instead of https://host:60443/owner/repo/...,
which 404s or hits the wrong service.

parseGitRemoteLocation discarded parsed.port (GitRemoteLocation had no
port field), and buildForgeBranchTreeUrl / buildForgeBlobUrl rebuilt the
origin from the portless host. Preserve the port on GitRemoteLocation and
reattach it in the web-URL builders, only for self-hosted http(s) origins
(an SSH port isn't the web port; a canonicalized cloud host uses the
default port). Host-identity matching (forge detection, cloud-host checks)
stays port-agnostic.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 04:00:46 +08:00
c596e058cd fix(dev): make worktree setup run on Windows (#2431)
`worktree.setup` ran two POSIX-only command strings, but lifecycle commands
go through PowerShell on Windows, so worktree creation failed at:

    PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=... ./scripts/dev-home.sh
    -> PASEO_DEV_MANAGED_HOME=1 : The term ... is not recognized

PowerShell has no `VAR=value cmd` prefix syntax. The `cp` entry was broken the
same way: `$PASEO_SOURCE_CHECKOUT_PATH` is an undefined *PowerShell* variable,
not an env var, so it expanded to empty and the copy resolved to
`/packages/server/.env`.

Neither entry can be expressed portably in a single shell string, and `bash` is
not guaranteed on Windows, so move both steps into a Node script that reads its
inputs from `process.env` — matching the existing
`node ./scripts/seed-ios-native-cache.mjs` entry. One code path, no platform
branching.

`scripts/dev-home.sh` is unchanged and still sourced by the bash service
scripts; only the setup-time seeding is ported.

One behavior change: a missing `packages/server/.env` in the source checkout is
now skipped with a log instead of aborting setup. It is untracked local config,
and the old `cp` hard-failed worktree creation for anyone without one.

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:52:26 +08:00
Mohamed Boudra fa1198c2be Stop completed turns from appearing stuck (#2484)
* fix(app): stop completed turns from appearing stuck

Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow.

* fix(app): guard pending message identity

* test(app): measure submission layout independent of scroll

* fix(app): keep submitted messages consistent through reconnects

Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization.

* fix(app): close canonical submission races

* test(app): cover canonical submission races in browser

* fix(app): keep replacement submissions authoritative

Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle.

* fix(app): keep submitted messages stable across sync

Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows.

* fix(app): settle submissions in either acknowledgement order

Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity.

* fix(app): keep agent visible during history handoff

Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied.

* fix(app): settle attachment-only submissions

Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row.

* fix(agent): settle out-of-band message submissions

Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer.

* fix(app): settle out-of-band submissions compatibly

* fix(app): preserve canonical prompt order

* test(app): keep workspace status check timing-independent

The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract.
2026-07-27 21:27:10 +02:00
paseo-ai[bot] 1f253d92e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-27 18:00:50 +00:00
Mohamed Boudra 43cf858c37 chore(release): cut 0.2.3 2026-07-27 19:46:38 +02:00
Mohamed Boudra 5d397754bd docs: add 0.2.3 changelog 2026-07-27 19:43:09 +02:00
Mohamed Boudra 2acb10fce9 Keep provider settings above the model selector (#2476)
* fix(app): keep provider settings above model selector

Desktop web comboboxes used the browser top layer, so ordinary modal portals could not cover them. Keep web overlays in one relative layer stack while preserving native bottom-sheet stacking.

* fix(app): route overlay keyboard ownership

* fix(app): preserve nested overlay ownership

* fix(app): route command center overlay input

* fix(app): preserve global overlay ancestry

* fix(app): register global dialog hosts
2026-07-27 14:04:29 +02:00
Mohamed Boudra e1b1ca569d Keep large file views from disconnecting (#2482)
* fix(files): keep large downloads connected

Large file reads were emitted as one frame, crossing the physical socket high-water mark and terminating otherwise healthy connections. Stream bounded chunks from one file handle and pace each transfer by its own send completion without globally queueing unrelated traffic.

* fix(files): keep paced relay transfers bounded

Carry send completion through encryption to the physical WebSocket callback, cap growing files at their advertised size, and preserve text classification when a UTF-8 code point crosses the sample boundary.

* fix(files): reject changing transfer snapshots

Abort when a file shrinks below its advertised size, finalize UTF-8 validation for complete samples, and let the physical relay socket remain the sole authority for queued-byte accounting.

* fix(files): validate metadata before transfer

Keep the daemon handshake pending until the ready frame is physically sent, and classify file content with a bounded full-handle scan so streaming preserves the previous binary/text behavior.

* fix(files): detect changing transfer sources

* fix(files): enforce transfer snapshot integrity
2026-07-27 13:52:57 +02:00
Mohamed Boudra b97d6d13f3 Load complete chat history when reaching the top (#2481)
* fix(chat): load older timeline history consistently

Pagination depended on scroll events, so short or compacted initial pages could never request older history. Reevaluate edge visibility as layout and history change, and standardize loading indicators on the canonical app spinner.

* fix(chat): keep older history loading reliably

Use authoritative timeline cursor progress so merged rows cannot stall pagination. Wait for bottom anchoring before evaluating the history edge, and re-arm retries on a fresh upward gesture without automatic failure loops.
2026-07-27 18:21:07 +08:00
Mohamed Boudra 80c8a08393 Keep parent agents alive while child work runs (#2458)
* fix(server): keep active agent trees resident

Idle collection treated an inactive parent turn as process quiescence even when descendant work was still running. Protect managed and provider-owned subagent trees, carry descendant activity into the idle window, and use a conservative 30-minute fallback.

* fix(server): close idle collection races

Retain managed ancestry across closed intermediates, refresh descendant state after each awaited close, and invalidate running provider subagents when their runtime is terminated.

* fix(server): serialize agent tree collection

Keep ancestors resident until managed descendants are collected, serialize descendant registration with idle collection, and preserve provider subagent state across hot reloads.

* fix(server): retain closed descendant activity

Carry persisted descendant activity through runtime closure, avoid error children pinning parents, and cancel stale provider children when reload replacement aborts.

* test(server): make descendant expiry deterministic

* fix(server): scope idle child protection

* fix(server): tighten idle tree coordination

* fix(server): reduce aggressive idle cleanup

Keep the mitigation conservative: extend the idle timeout without inferring tree liveness from incomplete lifecycle signals.

* fix(server): keep parents alive during child work

Idle cleanup now respects running managed and provider-native children while retaining a conservative 30-minute fallback.

* fix(server): clear running child state on close

* fix(server): drain child events before close
2026-07-27 12:12:22 +02:00
Mohamed Boudra 1d1132de9c perf(relay): reduce encrypted binary traffic overhead (#2480)
Preserve application frame identity through encryption so negotiated binary traffic avoids base64 expansion while mixed-version peers remain compatible.
2026-07-27 12:12:02 +02:00
Mohamed Boudra 1a1ff8828f feat(editor): wrap long Markdown lines (#2459) 2026-07-27 00:20:39 +02:00
Mohamed Boudra bb6231d556 fix(app): focus file pane when editing beside an agent (#2457)
CodeMirror uses a contenteditable surface, which the split-pane focus filter treated as exempt. Let editor interactions claim pane focus just like composer text inputs do.
2026-07-26 23:14:37 +02:00
Mohamed Boudra 07aa48cd6e docs(claude): explain reauthentication (#2455) 2026-07-27 04:48:47 +08:00
Mohamed Boudra 392095c1b2 feat(desktop): stop the daemon when you quit the app (#2454)
Quitting the desktop app now shuts down the daemon it started, so
"restart the app" is a complete reset users can act on without first
learning what a daemon is. The old default kept it alive; the toggle
under Settings > Host still opts back in.

Existing installs already persisted `keepRunningAfterQuit: true` from
the old default, so a new default alone would only reach fresh installs.
A one-time migration resets it, and an explicit toggle afterwards
persists the migration flag and is never overridden again.

Only a desktop-managed daemon is stopped — one started with
`paseo daemon start` is left alone.
2026-07-26 21:55:37 +02:00
Mohamed Boudra fe28850fad docs(relay): point readers to the official service
Remove the maintainer callout and star history chart from the README while keeping translated copies aligned.
2026-07-26 20:47:25 +02:00
Mohamed Boudra 72c7d3fe3e fix(app): keep streamed chat position stable on Android
Ignore synthetic momentum-end events emitted after native anchor corrections. Only active user momentum may settle sticky-bottom intent.
2026-07-26 20:47:25 +02:00
paseo-ai[bot] e859d2df12 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-26 08:10:45 +00:00
Mohamed Boudra c9fb31f709 Fix Claude 5 context window selection (#2433)
* fix(claude): select the correct Opus 5 context window

Bare Opus 5 IDs resolve to 200K behind third-party gateways. Expose explicit context variants and require a Claude Code version that recognizes the model.

* fix(claude): add Fable and Sonnet context variants

* test(cli): expect Claude context variants

* test(cli): compose Claude catalog expectations

* fix(claude): preserve catalog on version probe failure

* fix(claude): preserve parsed context variants

* chore(release): cut 0.2.2
2026-07-26 10:03:44 +02:00
维她命@ 51ab86bab6 test(cli): include Opus 5 in provider expectations (#2425) 2026-07-25 20:45:55 +02:00
paseo-ai[bot] 65633004b2 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-24 20:29:27 +00:00
Mohamed Boudra 8409e237ed Merge v0.2.1 release into main 2026-07-24 22:20:25 +02:00
Mohamed Boudra be52347d67 Let Hub finish the executions it starts (#2395)
* feat(server): control Hub execution lifecycle

* fix(server): archive only execution-owned worktrees

* fix(server): serialize Hub execution control after create

* test(server): compare Hub worktree paths canonically

* fix(server): make absent Hub controls idempotent

A Hub can own an execution before agent creation materializes. Treating an absent daemon-scoped record as already stopped or archived lets finality complete without revealing or affecting another daemon's execution.
2026-07-24 22:03:33 +02:00
paseo-ai[bot] 457679d45a fix: update lockfile signatures and Nix hash [skip ci] 2026-07-24 18:46:41 +00:00
Jason@HND 830c9b62c4 fix(server): retry hub test temp cleanup on Linux ENOTEMPTY (#2233)
removeRoot() already retries EBUSY/ENOTEMPTY on main; also retry EPERM
and document the Linux CI teardown race.

Closes #2207
2026-07-24 20:39:02 +02:00
Mohamed Boudra 36f38245ca chore(release): cut 0.2.1 2026-07-24 19:40:41 +02:00
Mohamed Boudra 782b341b1a docs: prepare 0.2.1 changelog 2026-07-24 19:37:33 +02:00
Mohamed Boudra 7ef3376b62 feat(claude): add Opus 5 model 2026-07-24 19:37:27 +02:00
bb3f5c5a2d fix: prevent Shift+Tab from changing a backgrounded agent's mode (#1848)
* fix: prevent Shift+Tab from changing a backgrounded agent's mode

The New Workspace mode-cycle shortcut (Shift+Tab) could reach a
still-mounted background agent's mode control and silently change that
running agent's execution mode — including into a permissive/bypass
mode — with no feedback in the New Workspace UI.

Root cause is in useKeyboardActionHandler: it re-registered its handler
on every render (a fresh inline `actions` array plus a changing `handle`
identity). The dispatcher runs the most-recently-registered matching
handler first, so a frequently re-rendering control climbed ahead of the
active one and consumed the key. The registered `handle`/`enabled` were
also captured per registration and read stale by the native keydown
listener.

Fix: register once per (handlerId, priority, actions); read `handle` and
`isActive` live from a ref; fold `enabled` into a fresh `isActive` so
the dispatcher re-checks it at dispatch time. Registration order stays
stable and callbacks stay current for all six consumers of the hook.

Add an e2e regression test that opens a live agent, opens New
Workspace, and presses Shift+Tab. It asserts the running agent's mode
is unchanged: its daemon-committed mode, plus no set_agent_mode_request
on the wire. It fails on the previous code (the mode was flipped to a
more permissive mode) and passes with this change.

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

* fix(app): restore workspace pin shortcut build

The shortcut change landed with an import path removed by the workspace tabs reshape, breaking app typecheck and web builds. Import the canonical workspace tab model.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-24 14:08:25 +00:00
Josh BendavidandMohamed Boudra a5942ef2de fix(omp): limit thinking levels to model's reported efforts (#2171)
* fix(omp): limit thinking levels to model's reported efforts

The omp provider exposed all six thinking levels (off/minimal/low/
medium/high/xhigh) whenever a model had reasoning enabled, ignoring the
per-model thinking config that omp reports via RPC. The OmpModelSchema
didn't parse the thinking field at all, so the model's efforts subset
and defaultLevel were discarded.

Parse model.thinking (efforts/defaultLevel/effortMap) in OmpModelSchema
and filter the thinking options in mapOmpModel to only the model's
reported efforts. The default is the reported defaultLevel when it's
in the filtered set, otherwise the first (lowest) effort. Older omp
versions that don't report thinking.efforts keep getting the full set.

Also fix createSession to not hardcode 'medium' as the thinking fallback
when the client doesn't send a thinkingOptionId — pass undefined so omp
uses its own model default instead of overriding it with a level that
may not even be in the model's effort set.

* test(omp): drop --thinking medium from provider-registry launch assertions

The thinking-level filtering PR changed createSession to pass undefined
instead of "medium" when no thinking option is selected, so buildOmpLaunch
no longer emits --thinking. agent.test.ts was updated but provider-registry.test.ts
still expected --thinking medium in two OMP launch assertions, failing server-tests
on ubuntu and windows.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-24 21:54:46 +08:00
Christoph LeiterandClaude Opus 4.8 73e290bb57 fix(server): name both refs in the base ref mismatch error (#2161)
The base ref mismatch error printed the same value on both sides, so it
always read "expected master, got master". All four throw sites computed
`baseRef = compare.baseRef ?? resolvedBaseRef`, but the guard only fires
when the caller passed a ref, so `baseRef` and the "got" value were the
same string by construction. The value that actually differs -- the
stored `baseRefName` from the worktree metadata -- was never shown,
which left the error undiagnosable from the UI alone.

Build the message in one place and have the four guards throw it, naming
the refs `stored` and `requested`. The wording deliberately avoids
"expected": a caller's ref can be stale, but the stored ref can equally
be the wrong one, so the message states both facts and leaves the
diagnosis open.

The guards stay where they are. Only the message moves, so the condition
that fires each throw is still visible at the call site, and both refs
are narrowed to plain strings by the time the message is built.

Throw conditions are unchanged; only the message text differs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:17:55 +08:00
Christoph LeiterandClaude Opus 4.8 e22c85373c fix(server): keep the client port in X-Forwarded-Host (#2288)
* fix(server): keep the client port in X-Forwarded-Host

The service proxy stripped the port when setting X-Forwarded-Host, so a
workspace service that derives its public origin from forwarded headers
emitted absolute URLs pointing at port 80. Opening a service at
http://api--branch--repo.localhost:6767/ and letting it redirect gave
back a Location without the port, which the browser followed to nothing.

Host was already forwarded intact, so services reading Host were fine.
Nothing rewrites Location on the way back, so the wrong URL reached the
browser unchanged.

The strip was not localhost-only: buildPublicServiceProxyUrl preserves
an explicit port in publicBaseUrl, so a public alias on :8443 hit the
same path. It was a no-op only on the default-port public case.

Forward the authority verbatim instead, and add X-Forwarded-Port under a
never-invent, never-clobber rule: report only a port observed in the
Host header, and leave any value an upstream proxy already set alone.
Deriving it from the scheme would overwrite nginx's port on a
non-default listener, and the upgrade path's hardcoded "http" would turn
a correct 443 into 80. An empty inbound value carries no port, so it
does not count as a value to preserve; out-of-range ports are dropped
rather than passed on, since the authority is client-supplied.

The header block existed in four copies. Collapse them: the subsystem
now delegates to createScriptProxyMiddleware and
createScriptProxyUpgradeHandler (passing passthroughUnknown explicitly,
since the factory defaults it to true), and both remaining call sites
share one buildForwardedHeaders helper.

Adds the first tests to cover any x-forwarded-* header on this path,
driven over a real proxy with an upstream that echoes what it received.

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

* fix(server): let the observed authority win over a forged port

X-Forwarded-Port deferred to an inbound value, so a client connecting
directly could send Host: svc.localhost:63735 with a forged
X-Forwarded-Port: 443 and have services build URLs for 443. Frameworks
apply X-Forwarded-Port after X-Forwarded-Host, so the forged value won.

This was inconsistent with the line above it: X-Forwarded-Host is
overwritten with the real authority unconditionally, and there is a test
asserting that. Both headers carry the same trust, so both now follow
the same rule.

First-hand observation wins; an inbound value is a fallback, never an
override. A port parsed from Host replaces any inbound X-Forwarded-Port.
When Host carries no port there is nothing to observe, so a proxy's
value survives untouched: the nginx-on-:8443 case, where $host drops
the port and X-Forwarded-Port is the only source.

The empty-inbound-value special case is gone; always overwriting when we
have an observation covers it with one branch less.

Reported by Greptile on #2288. Not a regression: before this branch an
inbound X-Forwarded-Port passed through untouched, so the forged value
already reached services.

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

* docs(server): correct the forwarded-port comment and note the limit

The comment claimed the parsed port was "the port the client really
connected on". It is not: parseHostHeaderPort reads the Host header,
which the client writes, and route lookup normalizes the port away
before matching, so any value reaches the proxy. The only observed
port would be req.socket.localPort, which this code does not use.

Replaced it with the actual reason the Host port wins, which is keeping
x-forwarded-host and x-forwarded-port from disagreeing: the former is
set from Host, and frameworks apply the latter afterwards, so a
mismatched inbound value would silently override the forwarded
authority.

Added a LIMITATION note recording that the forwarded authority is not
authenticated, that inbound x-forwarded-port is not checked against
trustedProxies, and that closing this needs that config threaded into
the subsystem and the upgrade path, which has no Express trust context.
Both predate this branch: Host has always carried a client-chosen port
and an inbound x-forwarded-port has always passed straight through.

Docs gain a matching section telling service authors to pin their public
origin in configuration rather than derive it from request headers.

No behavior change. Raised by Greptile on #2288.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:17:16 +08:00
Christoph LeiterandClaude Opus 4.8 cf36b2cc40 fix(app): pin the active workspace from a collapsed sidebar section (#2299)
Cmd+Shift+P did nothing whenever the active workspace's sidebar row was
not rendered. The `workspace.pin` handler was registered by the row
itself, gated on `selected && canPin`, so collapsing the row's section
unmounted the only handler for the action and the dispatcher found zero
candidates. The keypress was swallowed with no toast and no error.

Move the action to a single always-mounted handler keyed on the active
route selection, following the existing `useGlobalNewWorkspaceAction` /
`useActiveWorktreeNewAction` pattern, and delete the two per-row
registrations. Besides the reported case this also fixes a collapsed
status group, a collapsed Pinned section (so unpinning works too), and
focus mode.

The handler lives in a headless component rather than being called from
the root layout, so subscribing to the active workspace's pin state does
not re-render the whole app shell.

Two supporting changes:

- The controller now takes a narrow `PinnableWorkspace` instead of a
  full `SidebarWorkspaceEntry`, so a caller without a sidebar row can
  build one. Its in-flight guard moves to module scope, because the row
  menus and the shortcut hold separate controller instances and a
  per-instance guard would let a keypress and a menu click fire two
  concurrent, opposite RPCs.
- The handler resolves the descriptor id via `useWorkspaceFields` rather
  than reusing the route id. The route carries an opaque workspace id
  that is not guaranteed to equal the descriptor id, which is why
  `selectWorkspace` resolves it through
  `resolveWorkspaceMapKeyByIdentity`. Both the RPC and the in-flight key
  need the descriptor id so that rows and this handler agree on one
  identity.

`workspace.archive` has the same row-scoped defect and is deliberately
left alone: it carries per-row state (`isArchiving`, optimistic hiding,
the risky-worktree confirm) and needs its own change.

Covered by a Playwright spec: the three collapse cases fail without this
fix, plus one-RPC-per-press and a rejected-pin case that asserts the
error toast and that the next press still succeeds.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:16:37 +08:00
Christoph LeiterandClaude Opus 4.8 99440201bd fix(server): show Claude model-scoped weekly usage limits (#2303)
The Claude usage card showed only Session and Weekly. Anthropic moved
model-scoped weekly limits out of the top-level `seven_day_<model>` keys
into a `limits[]` array, and `seven_day_opus` / `seven_day_omelette` now
return null, so the scoped bar silently disappeared. Nothing errored, so
the card looked healthy while under-reporting.

Scoped limits are now read from `limits[]`, restoring a `Weekly · Fable`
bar. Session and all-models weekly keep coming from the top-level keys,
which is also where the Claude CLI reads them from.

A response mid-migration can describe one limit twice, as a legacy key
and as a `limits[]` entry, so both shapes normalize into a single
`ScopedLimit` and one predicate decides whether two are the same limit:
same dimension, same id when both carry one, else same normalized name.
That is the only comparison in the file. Comparing display labels would
conflate a surface with a model of the same name, and collapse ids that
differ only by punctuation.

The `limits[]` entry supplies identity, since that is the shape the API
is migrating towards. Its `percent` and `resets_at` are nullable, so
each field falls back to the legacy twin rather than discarding a value
the response did carry.

Legacy scoped windows adopt the same id scheme, so `weekly_opus` becomes
`weekly_model_opus`. Window ids are internal React keys, not
user-facing, and unifying them is what lets a limit keep one id
whichever shape of the response carried it.

Two supporting changes:

- `limits[]` entries are validated one at a time. The response goes
  through a single parse, so one malformed entry would otherwise throw
  and take the windows that already parsed with it.
- The provider stores the logger it was already handed and warns when a
  response parses but yields no windows, when a scope resolves to no
  name, and when an entry is unparseable. This failure was silent at
  every level, which is why it went unnoticed. Warn rather than debug,
  because file logging defaults to info.

Scoped windows render even at 0% and inactive, so a bar does not appear
and disappear between refreshes.

Closes #2302

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:16:05 +08:00
Christoph LeiterandClaude Opus 4.8 19565f6605 fix(app): show project name in command center workspace search (#2345)
Searching the command center for a branch name like "master" returned a
Workspaces list where every row looked identical: the title was the
branch and the subtitle read "<hostname> · <branch>". On a single-host
machine the hostname repeats on every row, so there was no way to tell
which project each "master" workspace belonged to.

Add the project name to the workspace subtitle (host · project · branch)
and gate the host label behind multi-host, matching the Agents section.
The host is dropped on single-host setups, so the subtitle reads
"project · branch" and rows are distinguishable. Because searchText is
derived from the subtitle, typing a project name now matches its
workspaces too.

Extract the shared join into joinSubtitleParts() and route both the
workspace and agent subtitles through it, removing the duplicated
filter/join the Agents section hand-rolled.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:15:50 +08:00
Mohamed Boudra 055db7454d Stop stale client sockets from exhausting daemon memory (#2169)
* fix(server): bound stale websocket connections

Track application ownership per physical socket and terminate abandoned or backpressured transports without disrupting replacement connections.

* fix(server): tighten relay backpressure accounting

Count pending encryption and transport backlog together, and release socket leases before forced termination.

* refactor(server): reuse client heartbeat for socket liveness

* refactor(server): remove unused websocket close codes
2026-07-24 15:14:37 +02:00
Mohamed Boudra 7250009ab7 feat(app): copy terminal IDs from tab menus (#2371) 2026-07-24 15:12:29 +02:00
Matt CowgerandMohamed Boudra 21404fbdec feat(cli): manage workspace scripts (#1992)
* feat(cli): manage workspace scripts

* docs: document workspace script management

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-24 21:12:14 +08:00
Mohamed Boudra 21597bdc1b Fix image uploads using the wrong format (#2380)
* fix(app): preserve image attachment MIME types

Image intake discarded clipboard and desktop path metadata, then guessed JPEG when a later file object had no type. Resolve MIME once at the platform boundary and require it downstream.

* fix(app): honor native picker MIME metadata
2026-07-24 21:11:41 +08:00
Mohamed Boudra 967edab497 Connect daemons to Hub through browser approval (#2208)
* feat(cli): connect daemons to Hub through browser approval

Let interactive setup complete through a short-lived browser authorization while preserving --token for automation and existing integrations.

* ci: build server before CLI tests

* fix(cli): keep Hub approval polling within expiry

Bound both response headers and body consumption to the original authorization lifetime so stalled responses remain retryable without extending approval. Run the CLI test helper through Node and the resolved tsx entry for cross-platform behavior.

* fix(cli): bound Hub registration startup

Fail a stalled authorization start within a fixed timeout without retrying the non-idempotent request. Declare the response validator as a direct CLI runtime dependency.

* fix(cli): restrict Hub activation URLs

* fix(cli): harden Hub approval transport
2026-07-24 21:10:21 +08:00
Mohamed Boudra b218267c3c Give Android store builds more memory
Use a large EAS worker for production Android store builds so native ABI compilation cannot OOM-kill Hermes.
2026-07-24 14:21:44 +02:00
Mohamed Boudra 5170833961 fix(providers): keep Pi on the native integration (#2392) 2026-07-24 20:17:29 +08:00
paseo-ai[bot] f4136c6d3e fix: update lockfile signatures and Nix hash [skip ci] 2026-07-24 11:36:07 +00:00
Mohamed Boudra d98c5e77f7 chore(release): cut 0.2.0 2026-07-24 13:27:13 +02:00
Mohamed Boudra fbbdbdc571 docs: prepare 0.2.0 changelog 2026-07-24 13:24:33 +02:00
Mohamed Boudra 7e97ab4a9c chore(app): refresh ACP provider catalog 2026-07-24 13:24:23 +02:00
Mohamed Boudra 0cb9ecf44b Merge branch 'main' of github.com:getpaseo/paseo 2026-07-24 12:48:03 +02:00
Mohamed Boudra afcd972dd0 Keep terminal pairing QR codes scannable (#2381)
* fix(pairing): keep terminal QR codes scannable

Prompt framing could wrap dense QR codes and alter long pairing links. Print pairing instructions directly, add the standard quiet zone, and suppress codes that cannot fit without auto-wrapping.

* fix(pairing): contain terminal QR background
2026-07-24 18:47:23 +08:00
Mohamed Boudra 48b14d27a5 fix(app): align and simplify provider rows 2026-07-24 12:46:46 +02:00
Mohamed Boudra 609f81bc11 Keep development tool versions consistent across version managers (#2390)
* chore(dev): centralize tool versions

* docs(android): clarify SDK version source

* fix(dev): keep mise setup installable
2026-07-24 18:46:10 +08:00
Mohamed Boudra 779a56ed36 fix(app): hide Markdown source toggle when editing is unavailable (#2382) 2026-07-24 18:14:08 +08:00
Mohamed Boudra 7bd4afe848 docs(providers): add Codex setup guide (#2389) 2026-07-24 09:57:20 +00:00
Mohamed Boudra fc10c79e26 fix(app): synchronize chat submission and keyboard state
Render optimistic turn feedback before host acknowledgement and roll it back on rejection. Reconcile iOS keyboard offsets from the native transition end event so JS contention cannot leave the composer displaced.
2026-07-24 11:52:31 +02:00
Ethan Greenfeld 08c522c986 Render live omp system-notices as synthetic tool calls (#2218)
Route live OMP custom messages through the existing system-notice mapper so background notices render as task notifications while advisor, hidden, and ordinary custom-message behavior stays intact.
2026-07-24 11:02:52 +02:00
paseo-ai[bot] 4b5551d61c fix: update lockfile signatures and Nix hash [skip ci] 2026-07-23 22:25:59 +00:00
Mohamed Boudra b02acb882c chore(release): cut 0.2.0-beta.4 2026-07-24 00:16:25 +02:00
Mohamed Boudra 13bce05630 docs: prepare 0.2.0-beta.4 changelog 2026-07-23 23:33:37 +02:00
Mohamed Boudra 17c12e2e1a chore(acp): update provider catalog versions 2026-07-23 23:33:31 +02:00
Mohamed Boudra c469ac124a fix(app): show only workspace commits in Changes
Keep base history out of the commit list and explain the empty workspace state.
2026-07-23 23:29:58 +02:00
Mohamed Boudra 09cfdecbbf fix(app): release inactive query caches
Ordinary queries were retained for the renderer lifetime, allowing large file previews to accumulate. Explicit replica and local-state queries keep their own lifetime policies.
2026-07-23 22:48:41 +02:00
Mohamed Boudra 1c95f8c37e fix(server): stop workspace updates triggering full scans (#2379)
Workspace update fanout was constructing one-off reconciliation services, so bursts could launch overlapping all-workspace scans. Keep reconciliation in the daemon service and preserve runtime cleanup for missing workspaces.
2026-07-23 22:36:42 +02:00
Mohamed Boudra 12612f6646 Make Git slowdowns visible in daemon metrics (#2366)
* feat(server): expose daemon Git pressure metrics

Separate limiter queue wait from Git execution time and report subscription ownership so accumulating work is visible in the existing runtime log.

* fix(server): collect subscriptions in agent metrics

Reuse the existing agent snapshot during runtime flushes so WebSocket shutdown does not require an additional AgentManager method call.

* test(server): retry transient hub cleanup
2026-07-23 21:57:08 +02:00
Mohamed Boudra a290f74705 fix(app): keep grouped tool-call shimmers full speed (#2369)
Retained group headings could enter loading after their initial layout, leaving React Native Web without a registered layout observer and the shimmer with a zero-length endpoint. Measure web badge labels from mount so later loading states reuse real dimensions.
2026-07-24 03:34:07 +08:00
Mohamed Boudra b73592ccac Fix web chat stickiness at non-default zoom (#2368)
Treat subpixel browser scroll rounding as the visual bottom while preserving material overscroll protection.
2026-07-23 21:08:12 +02:00
Mohamed Boudra 8a8f2baf80 Prevent duplicate ACP image prompts (#2363)
* fix(acp): prevent duplicate image prompts

Some ACP agents echo submitted prompts without preserving client message IDs. Attribute live user chunks to the active submitted turn, while coalescing provider-owned chunks outside it.

* fix(acp): flush user messages on failed turns

* fix(acp): flush user messages on session close
2026-07-23 20:43:57 +02:00
维她命@andMohamed Boudra a3438f96f8 fix(app): preserve file line endings and UTF-8 BOM (#2277)
* fix(app): preserve file line endings and UTF-8 BOM

* refactor(app): simplify line ending preservation

Let the editor parse newline variants and serialize with the file's first separator. Mixed-ending files become uniform on edit without a separate normalization layer.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-23 19:43:09 +02:00
Mohamed Boudra eb83e2bb45 fix(app): polish workspace controls 2026-07-23 19:39:11 +02:00
维她命@ 2bffd6e71e fix(server): clean up failed provider session initialization
Closes #2347
2026-07-23 19:38:33 +02:00
Mohamed Boudra 1ffa2f821a Stop archived workspaces from running background Git checks (#2355)
* fix(workspaces): stop archived workspace subscriptions

Workspace mutations were persisted globally but projected only through the initiating session, leaving other sessions' Git observers alive after archive. Publish mutations from the shared registry so every session releases its local observer and receives exactly one update.

* fix(workspaces): serialize workspace mutation observers

* fix(workspaces): finish mutation cleanup

Queued workspace mutations now stop at the session cleanup boundary and remove any observer created across an asynchronous cleanup race. Project removal can still enrich an earlier removal delta for legacy clients.

* fix(workspaces): honor global mutation boundaries

Serialize project and workspace lifecycle notifications per session, keep filtered subscriptions from owning unrelated Git observers, and broadcast final project removals to every session that saw the project. Update strict Session harnesses for the registry subscriptions.

* fix(workspaces): preserve initial agent status

Carry initial-agent intent through global workspace mutations and publish the optimistic running status until the agent contribution takes over. This removes the invalid transient Done state exposed by global subscriptions.
2026-07-23 19:16:28 +02:00
Mohamed Boudra fd0deea1c1 Publish smaller Android APKs for each architecture (#2349)
* feat(android): support F-Droid ABI-split builds

* test(android): remove source-fragment ABI assertions
2026-07-23 19:08:46 +02:00
yz e8fb9bda6c fix(app): size iPad selector popovers (#2360) 2026-07-24 00:45:00 +08:00
Byeonghoon Yoo 5e47cff58e fix(omp): honor hidden custom messages (#2280) 2026-07-24 00:41:36 +08:00
Mohamed Boudra 8e063f0dfc Fix compact composer controls and native scrolling (#2361)
* fix(app): refine compact composer controls and native scrolling

Consolidate responsive agent controls and model browsing inside the compact sheet. Preserve manual native scrolling by suspending sticky bottom maintenance while the user owns the viewport.

* fix(app): suppress recoverable resume refresh errors

Resume revalidation can race host reconnection and reject while the host is offline. Treat the refresh as deferred so development LogBox does not surface a recoverable disconnect.

* fix(app): stabilize compact model sheet loading

Let the model list consume the sheet space above persistent controls as the sheet expands. Keep unresolved defaults in a loading state so Select model only represents a genuinely empty selection.

* fix(app): preserve route anchors during native scroll

* fix(app): preserve native scroll intent and sheet dismissal

* fix(app): keep compact sheet styles composable
2026-07-24 00:24:10 +08:00
Mohamed Boudra 8cf70d10bf Show workspace commits clearly in Changes (#2350)
* feat(commits): distinguish workspace history from base commits

Keep every workspace commit visible while bounding base history to ten
context commits, and make push and base state readable in the commit rail.

* fix(commits): preserve history when base refs disappear

Fall back to recent HEAD history for stale saved bases, and reject truncated
git output instead of presenting an incomplete commit list.

* fix(commits): classify history against the current base

Use the furthest-ahead local or remote base for classification, and re-infer
base history when saved worktree metadata points to a deleted branch.
2026-07-23 12:17:48 +02:00
Matt Cowger 31c8dc3f05 Keep completed OpenCode turns idle (#2336)
Ignore post-turn metadata updates for user messages already emitted so completed OpenCode turns remain idle.
2026-07-23 10:01:14 +02:00
paseo-ai[bot] d1f19a5cdd fix: update lockfile signatures and Nix hash [skip ci] 2026-07-22 21:34:34 +00:00
Mohamed Boudra 8a1243e8d3 chore(release): cut 0.2.0-beta.3 2026-07-22 23:26:47 +02:00
Mohamed Boudra dd8a111c30 docs: add 0.2.0-beta.3 changelog 2026-07-22 22:46:24 +02:00
Mohamed Boudra 780c6513f1 chore(acp): refresh provider catalog versions 2026-07-22 22:46:17 +02:00
Mohamed Boudra 8b54d35818 fix(app): align Changes tab controls 2026-07-22 22:39:27 +02:00
Byeonghoon Yoo e699f07a17 fix(omp): complete delayed model turns after local-only results (#2282)
Wait briefly for extension-queued model turns before completing local-only OMP prompts, and preserve autonomous turn completion.
2026-07-22 20:33:04 +00:00
Slava Goltser 30b871e8d2 feat(omp): add write approval mode (#2228)
Expose OMP write approval mode in the provider manifest and launch configuration.
2026-07-22 20:09:45 +00:00
Josh Bendavid 894fa8516e fix(omp): accept all command source types in slash command schema (#2175)
OmpRpcSlashCommandSchema had a strict source enum
['extension', 'prompt', 'skill', 'builtin'] that rejected omp
commands with source 'custom' or 'file'. When omp reported any such
command, the entire get_available_commands response failed Zod
validation and listCommands returned an empty array — so the Paseo
autocomplete only showed /exit and /clear instead of all omp commands.

Relax source to z.string().optional(), matching the already-permissive
OmpAvailableCommandSchema used for the available_commands_update event.
2026-07-22 19:47:09 +00:00
Byeonghoon Yoo 35f5171477 fix(omp): complete turns when agent_end omits messages (#2261)
* fix(omp): complete turns after empty agent_end

* test(omp): remove timing flush from empty end regression
2026-07-22 21:42:16 +02:00
Mohamed Boudra 5dfe50ca74 Fix notifications opening the wrong workspace (#2331)
* fix(app): open notifications on the right host

Agent notifications previously omitted workspace ownership, so a cold target host was treated as missing and fell back to its empty home route. Carry the authoritative workspace and keep older notifications on a target-host resolver until lookup is conclusive.

* fix(app): separate notification and agent URL routing

Notifications use their authoritative workspace target directly. Stable agent URLs remain server-and-agent targets whose workspace is resolved by the agent route.

* fix(protocol): preserve notification workspace targets

Both attention message variants retain workspaceId through outbound validation so notification clicks receive the authoritative route target.

* test(app): use workspace-scoped notification target

* test(server): give notification agents workspace targets

* test(app): target notification workspace in navigation e2e
2026-07-23 03:32:28 +08:00
Christoph LeiterandClaude Opus 4.8 de69b2a2af fix(server): tone usage bars by how full they are (#2322)
A usage bar in Settings -> Usage stayed green all the way to 100%, so
the one moment the meter matters was the moment it said nothing.

The client already computes this. `window-bar.tsx` reads
`window.tone ?? deriveTone(usedPct)`, and `deriveTone` escalates past
70% and 90%. But `usage.ts` only assigns `window.tone` when a provider
sends one, so any provider that sends a tone opts out of the thresholds
entirely. Claude and Kimi hardcoded `tone: "ok"`, and Codex escalated to
`warning` past 70% but could never reach `danger`.

Providers now derive tone from the percentage they already have, through
one shared `toneFromUsedPct` whose thresholds match the client's.
Claude, Codex and Kimi windows all escalate correctly.

Balances had the same gap by a different route: `balance-bar.tsx` has no
`deriveTone` fallback, and `balanceToneFromRemaining` only escalates
once a balance is completely spent, so a credits bar at 99% was green.
Cursor and Grok know their limit, so they now tone by used percentage.
Codex credits report only a remaining balance with no limit, so they
keep the remaining-based tone, documented as the no-limit case.

MiniMax is unchanged: it maps a status its own API supplies rather than
hardcoding a tone.

Verified against the live Anthropic API with a session window at 83%,
which now reports `warning` where it previously reported `ok`.

Closes #2320

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:30:44 +08:00
Ethan Greenfeld 10da5ca169 Improve OMP state compatibility and advisor rendering (#2219)
* Accept OMP session state without thinkingLevel

Models whose reasoning effort is encoded in the model ID (e.g.
cursor-grok-4.5-high-fast) are marked reasoning: false by OMP, which
omits thinkingLevel from get_state. The required schema field made
session creation throw a ZodError. Make it optional and resolve the
thinking option to null when absent.

* Render OMP advisor messages as blocks
2026-07-23 03:25:12 +08:00
nikuscs 512c9b31a8 feat: open Changes as a workspace tab (#2298)
Open the complete working comparison in a desktop workspace tab while preserving inline sidebar diffs when the tab is closed.

Working and commit comparisons share one diff panel, and workspace layout is the sole owner of live tab persistence and identity.

Refs #1520
2026-07-22 20:57:18 +02:00
Mohamed Boudra 76a5edb020 Open existing agents from links and the CLI (#2324)
* feat(desktop): open existing agents from links

Register a stable agent deep link and route it through the existing Desktop window. Add a matching CLI command that resolves the local server and activates the requested agent without creating or messaging it.

* fix(desktop): recover agent link delivery
2026-07-22 18:31:12 +02:00
nikuscsandMohamed Boudra 9952615c33 fix: stop stale checkout diff subscriptions (#2317)
* Fix pending checkout diff subscriptions

* Reduce checkout status Git spawns

* fix(checkout): own pending diff cancellation

Checkout sessions could invalidate a subscription before its shared watch had finished opening. Register targets synchronously and let the diff manager honor cancellation so pending and concurrent subscriptions share one teardown path.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-22 16:49:44 +02:00
nikuscsandMohamed Boudra bd937850b3 feat: add workspace files to chat from Files and Changes
* feat(app): open changed files from context menu

* fix(app): preserve changed-file row behavior

* feat(app): add changed files to open chats

* feat(app): share file actions across explorer and changes

* feat(app): attach workspace files to focused chat

* feat(app): drag workspace files into chat

* refactor(app): use file actions menu for changed files

* fix(app): open changed-file actions on right click

* test(app): cover direct file attachment flow

* fix(app): enable add to chat on native

* fix(app): align workspace file pane rows

* refactor(app): simplify workspace file attachments

* fix(app): preserve uploaded file draft attachments

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-22 15:58:24 +02:00
Mohamed Boudra 42ee5a5949 Restore archived agents from History (#2316)
* fix(app): restore archived agents from History

History navigation lost the explicit archived-agent intent during tab reconciliation, and workspace recovery stopped after restoring the workspace. Preserve the selected tab and recover its provider session as one action while serializing provider resume with timeline hydration.

* fix(app): preserve archived agent recovery invariants

* fix(server): preserve timeline hydration call shape

* fix(app): retain agent tabs through lookup

* fix(server): upgrade in-flight timeline broadcast
2026-07-22 15:03:09 +02:00
1323 changed files with 98671 additions and 22820 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
Issues are only for bugs. Product discussions go in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH) — feature requests opened as issues get closed automatically.
Before opening, please:
+5 -2
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discord
- name: Product discussion on Discord
url: https://discord.gg/jz8T2uahpH
about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat.
about: Product discussion, including feature requests
- name: Product discussion on GitHub
url: https://github.com/getpaseo/paseo/discussions
about: Product discussion, including feature requests
@@ -1,43 +0,0 @@
name: Feature request
description: Propose a new feature or a change to existing behavior.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
- type: checkboxes
id: prior-search
attributes:
label: Prior search
options:
- label: I searched existing issues and discussions, and this isn't already proposed.
required: true
- type: textarea
id: problem
attributes:
label: What's the problem
description: What are you actually trying to do, and why is the current behavior in the way?
placeholder: |
When I'm doing X, I want to Y, but Paseo currently Z.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What would solve it
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
description: Optional. What else did you try, and why doesn't it work?
+18 -13
View File
@@ -1,27 +1,34 @@
<!--
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
You MUST read CONTRIBUTING.md before sending a PR.
-->
### Linked issue
Closes #
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
### Type of change
- [ ] Bug fix
- [ ] New feature (with prior issue + design alignment)
- [ ] Refactor / code improvement
- [ ] New feature
- [ ] Enhancement
- [ ] Refactor
- [ ] Docs
### What does this PR do
### Reasoning
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provider to Paseo users. -->
### How did you verify it
### Goals
<!-- A bullet list of what this PR wants to accomplish, include requirements and acceptance criteria. This helps lock down the scope of the PR and prevent expanding scope over the intended goal. -->
### Non-goals
<!-- A bullet list of what this PR does not want to accomplish. Add any intentional trade offs taken. This helps lock down the scope of the PR and prevent expanding scope over the intended goal. -->
### QA
<!--
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
@@ -29,15 +36,13 @@ This is the section I read most carefully. I need to see that *you* tested this,
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
- For behavior changes: the actual steps you ran, and what you observed.
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
-->
### Checklist
- [ ] One focused change. Unrelated cleanups split out.
- [ ] One focused change
- [ ] `npm run typecheck` passes
- [ ] `npm run lint` passes
- [ ] `npm run format` ran (Biome)
- [ ] UI changes include screenshots or video for every affected platform
- [ ] `npm run format` passes
- [ ] QA evidence
- [ ] Tests added or updated where it made sense
+71
View File
@@ -0,0 +1,71 @@
# Pull-request checks are routed by the behavior they prove. Package consumers
# do not inherit their dependencies' test suites; main and manual runs execute
# every contract regardless of these paths.
routing:
- ".github/ci-paths.yml"
workspace:
- ".mise.toml"
- ".tool-versions"
- "package.json"
- "package-lock.json"
- "patches/**"
- "scripts/**"
- "tsconfig.json"
- "tsconfig.base.json"
- "vitest.config.ts"
ci:
- ".github/actions/**"
- ".github/workflows/ci.yml"
format:
- ".agents/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}"
- ".github/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}"
- "**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}"
- "packages/expo-two-way-audio/**"
quality:
- "**/*.{cjs,js,json,jsx,mjs,ts,tsx}"
- "packages/expo-two-way-audio/**"
hub:
- "packages/cli/src/commands/hub/**"
- "packages/server/src/server/hub/**"
server:
- "packages/server/**"
- "packages/app/e2e/support/fixtures/recording.*"
desktop:
- "packages/desktop/**"
- "packages/app/src/desktop/**"
- "packages/server/src/server/browser-tools/**"
- "packages/app/e2e/support/**"
- "packages/app/*config.{cjs,js,ts}"
- "packages/app/package.json"
app:
- "packages/app/**"
- "packages/expo-two-way-audio/**"
sdk:
- "packages/client/**"
- "packages/highlight/**"
- "packages/protocol/**"
browser:
- "packages/app/src/!(desktop)/**"
- "packages/app/e2e/browser/**"
- "packages/app/e2e/support/**"
- "packages/app/assets/**"
- "packages/app/public/**"
- "packages/app/index.ts"
- "packages/app/*config.{cjs,js,ts}"
- "packages/app/package.json"
relay:
- "packages/relay/**"
cli:
- "packages/cli/**"
+205 -77
View File
@@ -18,11 +18,48 @@ env:
ONNXRUNTIME_NODE_INSTALL: skip
jobs:
changes:
name: changes
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
full: ${{ github.event_name != 'pull_request' || steps.filter.outputs.routing != 'false' || steps.filter.outputs.workspace != 'false' || steps.filter.outputs.ci != 'false' }}
format: ${{ steps.filter.outputs.format }}
quality: ${{ steps.filter.outputs.quality }}
hub: ${{ steps.filter.outputs.hub }}
server: ${{ steps.filter.outputs.server }}
desktop: ${{ steps.filter.outputs.desktop }}
app: ${{ steps.filter.outputs.app }}
sdk: ${{ steps.filter.outputs.sdk }}
browser: ${{ steps.filter.outputs.browser }}
relay: ${{ steps.filter.outputs.relay }}
cli: ${{ steps.filter.outputs.cli }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Detect affected CI contracts
id: filter
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
with:
filters: .github/ci-paths.yml
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs
format:
name: format
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
runs-on: ubuntu-latest
env:
# This job never executes Electron. Skipping the hosted binary avoids
# unrelated npm ci failures when Electron's CDN returns 504.
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -36,9 +73,12 @@ jobs:
run: node scripts/npm-retry.mjs ci
- name: Check formatting
run: npx oxfmt --check .
run: npm run format:check
lint:
name: lint
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -63,6 +103,9 @@ jobs:
run: npm run lint
typecheck:
name: typecheck
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -76,6 +119,7 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
@@ -88,16 +132,14 @@ jobs:
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: server-tests (${{ matrix.os }})
server-tests-ubuntu:
name: server-tests (ubuntu-latest)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
steps: &server_test_steps
- uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -108,45 +150,51 @@ jobs:
cache: "npm"
- name: Fetch origin/main (worktree tests)
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
run: npm run build:server-deps
- name: Run server tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
- name: Run Hub CLI/daemon contract
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
server-tests-windows:
name: server-tests (windows-latest)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: windows-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps: *server_test_steps
desktop-tests-ubuntu:
name: desktop-tests (ubuntu-latest)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
steps:
steps: &desktop_test_steps
- uses: actions/checkout@v4
- name: Detect desktop changes
if: matrix.os == 'ubuntu-latest'
id: desktop_changes
uses: dorny/paths-filter@v3
with:
filters: |
desktop:
- 'packages/desktop/**'
- uses: actions/setup-node@v4
with:
node-version: "22"
@@ -154,37 +202,42 @@ jobs:
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
- name: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop
- name: Build app dependencies for desktop E2E
if: matrix.os == 'ubuntu-latest'
run: npm run build:app-deps
- name: Install virtual display
if: matrix.os == 'ubuntu-latest'
if: ${{ runner.os == 'Linux' }}
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E
if: matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Install Playwright browser
if: ${{ runner.os == 'Linux' }}
run: npx playwright install chromium
- name: Upload browser tab bridge diagnostics
- name: Run desktop renderer E2E
if: ${{ runner.os == 'Linux' }}
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop
- name: Run desktop browser E2E
if: ${{ runner.os == 'Linux' }}
run: npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
env:
PASEO_DESKTOP_BROWSER_E2E_ARTIFACT_DIR: ${{ runner.temp }}/desktop-browser-e2e
- name: Upload desktop browser diagnostics
uses: actions/upload-artifact@v4
if: failure() && matrix.os == 'ubuntu-latest'
if: ${{ failure() && runner.os == 'Linux' }}
with:
name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e
name: desktop-browser-e2e
path: ${{ runner.temp }}/desktop-browser-e2e
if-no-files-found: ignore
retention-days: 7
- name: Build and smoke unpacked desktop app
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
if: ${{ runner.os == 'Linux' }}
run: npm run build:desktop -- --publish never --linux --x64 --dir
env:
EP_GH_IGNORE_TIME: true
@@ -192,15 +245,28 @@ jobs:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Upload packaged smoke diagnostics
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
uses: actions/upload-artifact@v4
if: ${{ failure() && runner.os == 'Linux' }}
with:
name: desktop-packaged-smoke-linux-x64
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
desktop-tests-windows:
name: desktop-tests (windows-latest)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
runs-on: windows-latest
timeout-minutes: 30
permissions:
contents: read
steps: *desktop_test_steps
app-tests:
name: app-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -214,6 +280,7 @@ jobs:
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
@@ -225,6 +292,9 @@ jobs:
run: npm run test --workspace=@getpaseo/app
sdk-tests:
name: sdk-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -238,6 +308,7 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build client dependencies
run: npm run build:client
@@ -247,24 +318,22 @@ jobs:
- name: Run client tests
run: npm run test --workspace=@getpaseo/client
- name: Run highlight tests
run: npm run test --workspace=@getpaseo/highlight
- name: Typecheck client examples
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
strategy:
fail-fast: false
matrix:
include:
- { label: "shard 1/4", shard: 1, desktop: false }
- { label: "shard 2/4", shard: 2, desktop: false }
- { label: "shard 3/4", shard: 3, desktop: false }
- { label: "shard 4/4", shard: 4, desktop: false }
- { label: "desktop overlay", shard: "desktop", desktop: true }
name: playwright (${{ matrix.label }})
playwright-1:
name: playwright (shard 1/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
PLAYWRIGHT_SHARD: "1/4"
PLAYWRIGHT_ARTIFACT: "1"
steps: &playwright_test_steps
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -274,6 +343,7 @@ jobs:
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
@@ -285,30 +355,60 @@ jobs:
run: npm run build:server
- name: Install agent CLIs for provider tests
if: ${{ !matrix.desktop }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: ${{ !matrix.desktop }}
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: ${{ matrix.desktop }}
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results-${{ matrix.shard }}
name: playwright-results-${{ env.PLAYWRIGHT_ARTIFACT }}
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
playwright-2:
name: playwright (shard 2/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "2/4"
PLAYWRIGHT_ARTIFACT: "2"
steps: *playwright_test_steps
playwright-3:
name: playwright (shard 3/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "3/4"
PLAYWRIGHT_ARTIFACT: "3"
steps: *playwright_test_steps
playwright-4:
name: playwright (shard 4/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "4/4"
PLAYWRIGHT_ARTIFACT: "4"
steps: *playwright_test_steps
relay-tests:
name: relay-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -329,16 +429,19 @@ jobs:
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
cli-tests:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
cli-tests-1:
name: cli-tests (shard 1/3)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
runs-on: ubuntu-latest
name: cli-tests (shard ${{ matrix.shard }}/3)
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: "1"
PASEO_CLI_TEST_SHARD_TOTAL: "3"
steps: &cli_test_steps
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -349,14 +452,39 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
PASEO_CLI_TEST_SHARD_TOTAL: "3"
cli-tests-2:
name: cli-tests (shard 2/3)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: "2"
PASEO_CLI_TEST_SHARD_TOTAL: "3"
steps: *cli_test_steps
cli-tests-3:
name: cli-tests (shard 3/3)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: "3"
PASEO_CLI_TEST_SHARD_TOTAL: "3"
steps: *cli_test_steps
+24
View File
@@ -104,3 +104,27 @@ jobs:
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop
build-desktop-darwin:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop
- name: Verify macOS application bundle
run: |
set -euo pipefail
app="result-desktop/Applications/Paseo.app"
test -x "$app/Contents/MacOS/Paseo"
test -L result-desktop/bin/paseo-desktop
test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" = "sh.paseo.desktop"
find "$app" -name Info.plist -print0 | xargs -0 -n 1 plutil -lint
+9 -9
View File
@@ -1,10 +1,10 @@
[env]
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
_.path = [
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator",
]
[vars]
android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}'
[tools]
java = "21"
[env]
ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}"
_.path = [
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator",
]
+6 -7
View File
@@ -130,7 +130,7 @@
]
}
},
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
// Raw query burn-down: 34 files total; the 28 files below still enforce the useUnistyles ban.
{
"files": [
"packages/app/src/assistant-file-links/use-file-link.ts",
@@ -154,10 +154,9 @@
"packages/app/src/hooks/use-is-local-daemon.ts",
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
"packages/app/src/hooks/use-preferred-editor.ts",
"packages/app/src/hooks/use-project-icon-query.ts",
"packages/app/src/hooks/use-settings/index.ts",
"packages/app/src/panels/terminal-panel.tsx",
"packages/app/src/projects/project-icons.ts",
"packages/app/src/projects/icons.ts",
"packages/app/src/provider-usage/use-provider-usage.ts",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
@@ -189,9 +188,9 @@
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/browser-pane.electron.tsx",
"packages/app/src/components/browser-pane.tsx",
"packages/app/src/components/browser-pane.web.tsx",
"packages/app/src/desktop/browser/pane/index.electron.tsx",
"packages/app/src/desktop/browser/pane/index.tsx",
"packages/app/src/desktop/browser/pane/index.web.tsx",
"packages/app/src/components/command-center.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/dictation-controls.tsx",
@@ -280,7 +279,7 @@
}
},
{
"files": ["**/e2e/fixtures.ts"],
"files": ["**/e2e/**/fixtures.ts"],
"rules": {
"no-empty-pattern": "off"
}
+117 -39
View File
@@ -1,56 +1,112 @@
# Changelog
## 0.2.0-beta.2 - 2026-07-22
### Added
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
### Improved
- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
## 0.2.5 - 2026-07-30
### Fixed
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
- Fixed the Linux Debian package ([#2654](https://github.com/getpaseo/paseo/pull/2654) by [@Neumannzc](https://github.com/Neumannzc))
## 0.2.0-beta.1 - 2026-07-17
## 0.2.4 - 2026-07-30
### Added
- Set an agent's thinking level from the CLI ([#2533](https://github.com/getpaseo/paseo/pull/2533))
- Switch projects while creating a workspace with ⌘P / Ctrl+P ([#2110](https://github.com/getpaseo/paseo/pull/2110) by [@turtleDev](https://github.com/turtleDev))
- Open a project or workspace folder from the sidebar ([#2491](https://github.com/getpaseo/paseo/pull/2491) by [@PTK030](https://github.com/PTK030))
- Flick up on the chat to dismiss the keyboard ([#2417](https://github.com/getpaseo/paseo/pull/2417) by [@nllptrx](https://github.com/nllptrx))
- Custom providers can read the agent's working directory ([#2563](https://github.com/getpaseo/paseo/pull/2563))
### Fixed
- The same repository added on more than one machine appears as one project in the sidebar ([#2565](https://github.com/getpaseo/paseo/pull/2565))
- Idle agents keep their background work instead of shutting down when unused ([#2590](https://github.com/getpaseo/paseo/pull/2590))
- Desktop no longer crashes at startup while restoring your file tree ([#2595](https://github.com/getpaseo/paseo/pull/2595))
- Plan approval only shows the latest proposal ([#2534](https://github.com/getpaseo/paseo/pull/2534))
- Opus 5 appears once in the model list, with its full 1M context ([#2497](https://github.com/getpaseo/paseo/pull/2497))
- The context meter no longer blanks out partway through a conversation ([#2494](https://github.com/getpaseo/paseo/pull/2494) by [@theslava](https://github.com/theslava))
- A very large working diff no longer drops your session ([#2488](https://github.com/getpaseo/paseo/pull/2488) by [@nikuscs](https://github.com/nikuscs))
- Interrupting Pi no longer surfaces a stream error ([#2311](https://github.com/getpaseo/paseo/pull/2311) by [@mcowger](https://github.com/mcowger))
- Grok usage shows again in Settings ([#2353](https://github.com/getpaseo/paseo/pull/2353) by [@jasonhnd](https://github.com/jasonhnd))
- OMP models that report no context window now load ([#2406](https://github.com/getpaseo/paseo/pull/2406) by [@astartsky](https://github.com/astartsky))
- Paseo's own tools are available directly in OMP ([#2418](https://github.com/getpaseo/paseo/pull/2418) by [@perezd](https://github.com/perezd))
- Codex finds the skills defined in your project ([#2423](https://github.com/getpaseo/paseo/pull/2423) by [@dwyanewang](https://github.com/dwyanewang))
- Pull request comments containing HTML render correctly ([#2432](https://github.com/getpaseo/paseo/pull/2432) by [@mcowger](https://github.com/mcowger))
- Self-hosted forge links keep their port ([#2478](https://github.com/getpaseo/paseo/pull/2478) by [@muzhi1991](https://github.com/muzhi1991))
- The Linux AppImage launches when opened from your desktop ([#2439](https://github.com/getpaseo/paseo/pull/2439) by [@stonegray](https://github.com/stonegray))
- Repository search works with older versions of the GitHub CLI ([#2611](https://github.com/getpaseo/paseo/pull/2611))
## 0.2.3 - 2026-07-27
### Added
- Manage workspace scripts from the CLI and agent MCP tools ([#1992](https://github.com/getpaseo/paseo/pull/1992) by [@mcowger](https://github.com/mcowger))
- Copy terminal IDs from terminal tab menus ([#2371](https://github.com/getpaseo/paseo/pull/2371))
- Long Markdown lines wrap by default in the file editor ([#2459](https://github.com/getpaseo/paseo/pull/2459))
### Improved
- Desktop stops its managed daemon when you quit unless “Keep daemon running after quit” is enabled ([#2454](https://github.com/getpaseo/paseo/pull/2454))
- Remote terminal and file traffic uses less bandwidth over encrypted connections ([#2480](https://github.com/getpaseo/paseo/pull/2480))
- Workspace search now shows and matches project names ([#2345](https://github.com/getpaseo/paseo/pull/2345) by [@cleiter](https://github.com/cleiter))
- Claude usage shows model-specific weekly limits ([#2303](https://github.com/getpaseo/paseo/pull/2303) by [@cleiter](https://github.com/cleiter))
- OMP models show only the thinking levels they support ([#2171](https://github.com/getpaseo/paseo/pull/2171) by [@bendavid](https://github.com/bendavid))
### Fixed
- Image uploads preserve the correct image format ([#2380](https://github.com/getpaseo/paseo/pull/2380))
- Large file views no longer disconnect the session ([#2482](https://github.com/getpaseo/paseo/pull/2482))
- Reaching the top of a chat loads the complete older history ([#2481](https://github.com/getpaseo/paseo/pull/2481))
- Parent agents stay available while child agents are working ([#2458](https://github.com/getpaseo/paseo/pull/2458))
- Stale client connections no longer exhaust daemon memory ([#2169](https://github.com/getpaseo/paseo/pull/2169))
- Pin and unpin shortcuts work when sidebar sections are collapsed ([#2299](https://github.com/getpaseo/paseo/pull/2299) by [@cleiter](https://github.com/cleiter))
- `Shift+Tab` no longer changes a background agent’s permission mode ([#1848](https://github.com/getpaseo/paseo/pull/1848) by [@cleiter](https://github.com/cleiter))
- Proxied services preserve ports in redirects ([#2288](https://github.com/getpaseo/paseo/pull/2288) by [@cleiter](https://github.com/cleiter))
- Provider settings open correctly above the model selector ([#2476](https://github.com/getpaseo/paseo/pull/2476))
- Clicking the file editor correctly focuses its pane ([#2457](https://github.com/getpaseo/paseo/pull/2457))
## 0.2.2 - 2026-07-25
### Fixed
- Claude 5 models now use the correct context windows.
## 0.2.1 - 2026-07-24
### Added
- Claude Opus 5 is available
## 0.2.0 - 2026-07-24
### Added
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr))
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang))
- Oh My Pi (OMP) as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
- Browse workspace commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
- Allow Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
### Improved
- ACP provider catalog updated to the latest registry versions
- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361))
- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim))
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- CLI and MCP tools manage workspaces, agents, and schedules more consistently ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
@@ -58,12 +114,34 @@
### Fixed
- Terminal pairing QR codes remain scannable in narrow terminals ([#2381](https://github.com/getpaseo/paseo/pull/2381))
- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379))
- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang))
- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger))
- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363))
- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368))
- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369))
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
- Oh My Pi background notices appear as task notifications instead of raw system text ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223))
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
## 0.1.110 - 2026-07-16
+38 -24
View File
@@ -19,7 +19,7 @@ This is an npm workspace monorepo:
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
| Doc | What's in it |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
@@ -29,7 +29,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
@@ -42,9 +42,11 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
@@ -55,6 +57,30 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
### Writing docs
- **Integrate, don't append.** Find the doc that owns the subject and rewrite the part that is now wrong. The standard failure is finishing a task and adding a paragraph to the bottom of the closest-looking doc; ten tasks later the doc is a pile of paragraphs in discovery order. `docs/custom-providers.md` is what that looks like.
- **Don't document logic.** Prose that restates code drifts from the code and loses. Write down what the code can't tell you: why something is shaped the way it is, the gotcha that cost an afternoon, conventions nothing enforces, constraints that span packages or versions. If a reader could get it in two minutes by opening the file, cut it.
- **One fact, one doc.** Every other mention is a link. If you are about to write the same paragraph in two docs, one of them is a link.
- **Respect the layers.** `CONTRIBUTING.md` and this file name things and link out. Activity docs like `docs/qa.md` and `docs/testing.md` set the bar for a kind of work. Subject docs like `docs/unistyles.md` own one thing completely. A layer never re-explains the one below it.
- **One subject per doc.** If the subject doesn't fit in a sentence, split the doc. A section per provider, vendor, or platform is a table plus one worked example.
- **Delete.** Obsolete sections go. Prefer a `packages/app/src/thing.ts:120` reference over a pasted block.
- **New doc?** Add a row to the table above and link it from the docs that should send readers there.
- Code-level facts belong in comments next to the code, not here.
### Doc voice
Plain and short. Second person. State the rule, then the reason when the reason isn't obvious. Match the doc you're editing.
Do not:
- Write a sentence to land a point. "It's not X, it's Y", "That's not a Z, that's a W", and every other setup-and-punchline shape.
- Add a clause that only asserts importance: "and that matters", "which is what keeps it working", "this is critical".
- Use "honest", "robust", "seamless", "powerful", "simply", "just", "delightful".
- Restate something you already said, in different words, for emphasis.
- Hedge with "generally", "typically", or "you may want to" when the answer is "do this".
- Clear your throat: "It's worth noting that", "In order to", "This section covers".
## Quick start
```bash
@@ -94,23 +120,11 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
- `npm run lint -- packages/app/src/components/message.tsx`
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default.
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
- Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
- **The protocol stays backward-compatible. Features don't have to.** Read [docs/protocol-compatibility.md](docs/protocol-compatibility.md) before touching `packages/protocol`. The short version:
- **Protocol contract (always):** an old client parses messages from a new daemon, and a new daemon parses messages from an old client. New fields are optional; never narrow, never remove, never require. Wire schemas stay pure — no `.transform()`, `.catch()`, or `.preprocess()`.
- **Feature contract (per-feature):** gate the capability once on `server_info.features.*`, then run the feature or tell the user to update the host. No fallback paths, no defensive branches.
- **Every shim is tagged.** `// COMPAT(name): added in vX, remove after <date>` at the site that has to be deleted. `rg "COMPAT\("` is the cleanup backlog; untagged back-compat is permanent by accident.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
## Platform gating
@@ -148,12 +162,12 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
```
components/
browser-pane.electron.tsx ← Electron <webview> implementation
browser-pane.web.tsx ← plain web fallback
browser-pane.tsx ← native fallback
desktop/browser/pane/
index.electron.tsx ← Electron <webview> implementation
index.web.tsx ← plain web fallback
index.tsx ← native fallback
```
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
Import as `@/desktop/browser/pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
+116 -32
View File
@@ -1,56 +1,140 @@
# Contributing to Paseo
Paseo is an opinionated product maintained by one person right now.
Thank you for taking the time to contribute to Paseo.
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
## Philosophy
Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
## Becoming a maintainer
Given Paseo's scope, contributing to to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
Here's the progression:
## Report bugs in GitHub issues or Discord
1. Get involved in the community: answer questions in Discord and on GitHub
2. Triage bugs: replicate and help fix them
3. Work on maintainer-approved features
Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH).
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
> [!IMPORTANT]
> Feature requests opened as issues will get closed automatically
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis.
You may submit a PR to fix a bug, please read the PR guidelines below.
## Product discussions go in GitHub Discussions or Discord
There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow:
- What are you trying to do?
- How do you do it today?
- Where does Paseo get in the way?
- What would the flow look like if it worked for you?
"Can you build X" is not useful because it doesn't tell me about your problem.
A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build.
Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me.
## Pull requests
✅ Will be accepted
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
- Keep it to one focused change
- Link to an issue
- Explain the problem you're solving
- Include repro steps if it's a bug
- Include QA/testing evidence
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
Here is the criteria I use to decide:
✅ Likely to be accepted
- Fits the product vision
- One focused change
- Links the bug it fixes, or the discussion behind it if there is one
- Addresses bot reviews
- Explains the problem you're solving
- QA evidence
- Automated tests
- Screenshots or video for UI changes, on every affected platform
- Says which platforms you tested and which you didn't
- Maintainer edits enabled
⛔️ Will be rejected
- Bundle unrelated changes
- Fail basic checks like typecheck, formatting or linting
- Add a feature or design change that wasn't discussed first
- Submit no evidence of testing
- Skip the linked issue
- Bundles unrelated changes
- Fails checks
- Ignores bot reviews
- Takes a feature or the design in a direction I don't want
- No QA evidence
- No tests
- Clearly fully AI-generated PR
## Requesting features
### What to expect
If you need a feature implemented, create a Github issue or a thread in Discord.
- PRs that were explicitly approved in a discussion are preferred.
- Unsolicited PR can be closed without a detailed review.
- Your PR can be narrowed, refactored, or redesigned.
- Your PR might be accpeted but not merged immediately.
- You wil be attributed for your work in the changelog, even if I redesign your PR
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
## QA evidence
## AI assistance
QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar.
Using AI to help write code is fine, but you must:
Pull requests without evidence will be closed.
- Ensure your agents read the docs
- Understand the code you submit
- Review and test the code yourself
What's accepted:
- The shell commands you ran, pasted with their output
- The tests you added and their results
- Before and after screenshots
- A video of the whole interaction
- Logs, requests, responses
Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static.
The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it.
## I want to become a maintainer
There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement.
## FAQ
### I'm blocked, where do I go?
Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there.
### Do you prefer Github or Discord?
I am more active on Discord for general questions.
I prefer Github for bugs because the template helps collect information more efficiently.
### Why was my feature request closed?
Issues are only for bugs. Open it as a product discussion instead, framed as a workflow.
### Why didn't my PR get a detailed review?
Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume.
### How long until someone looks at my pull request?
It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask.
### Why do you want tests and a video for a small change?
Because it's faster and safer than me checking out and running every PR myself to test it manually.
QA evidence help me take your PR seriously for a deeper review and maintainer QA.
### I only have one platform, can I still contribute?
Yes. Test what you have, and say what you didn't test.
### Can I use an agent to write my contribution?
Yes, as long as you understand what you're submitting and you tested it yourself.
### Is there a roadmap I can read?
[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next.
+3 -14
View File
@@ -151,23 +151,12 @@ npm run build:server
npm run typecheck
```
## コミュニティ
## 関連プロジェクト
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## ライセンス
AGPL-3.0
+6 -23
View File
@@ -38,12 +38,6 @@
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
> [!NOTE]
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
@@ -70,7 +64,7 @@ You need at least one agent CLI installed and configured with your credentials:
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, open **Settings → your host → Connections → Pair a device**.
To connect from your phone, open **Settings → your host → Pair Device**.
### CLI / headless
@@ -81,7 +75,7 @@ npm install -g @getpaseo/cli
paseo
```
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
For full setup and configuration, see:
@@ -144,7 +138,7 @@ Quick monorepo package map:
- `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows
- `packages/desktop`: Electron desktop app
- `packages/relay`: Relay package for remote connectivity
- `packages/relay`: Relay transport and encryption used by the daemon and clients
- `packages/website`: Marketing site and documentation (`paseo.sh`)
Common commands:
@@ -166,23 +160,12 @@ npm run build:server
npm run typecheck
```
## Community
## Related projects
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## License
AGPL-3.0
+3 -14
View File
@@ -151,9 +151,10 @@ npm run build:server
npm run typecheck
```
## 社区
## 相关项目
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
### 自托管 relay TLS
@@ -202,18 +203,6 @@ server {
}
```
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## License
AGPL-3.0
+15 -1
View File
@@ -22,7 +22,9 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -64,6 +66,18 @@ CORS is not a complete security boundary. It controls which browser origins can
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
## HTML file preview
Previewing an `.html` file in the file pane renders it as a page, so markup an agent wrote — or markup that arrived with a repo you cloned — executes when you open it. The preview is built to contain that, not to trust it.
The document loads with an opaque origin and a policy that permits inline script and style and refuses everything else: no remote script, font, image, or media; no `fetch`, XHR, WebSocket, or beacon; no form posts; no plugins; no nested frames. It has no access to Paseo's DOM, and storage and cookie APIs throw inside it rather than returning anything. It cannot navigate the top window, and it cannot open popups. It cannot read any file but itself.
One gap remains on web and desktop: a sandboxed document may navigate _itself_, and no CSP directive in current browsers prevents that. `navigate-to` was dropped from CSP Level 3 and is not enforced, and `<meta http-equiv="refresh">` needs no script at all. A hostile page can therefore reach a server by navigating away, carrying data available inside the preview, such as its own contents, browser and device properties, user input inside the page, and your IP address. It cannot read Paseo, another file, storage, or cookies.
Native builds narrow this gap rather than closing it outright. The WebView refuses every navigation after the initial document, but that decision is made in the app's JavaScript, and on Android the WebView falls back to allowing a navigation when the decision doesn't come back in time. Treat it as a strong mitigation, not a guarantee: if the JS thread is stalled at the moment a page navigates, the same leak is possible there too.
If you don't trust a page, read it in `Source`, which executes nothing. Source is available as an editable view on supported web hosts and a read-only view everywhere else.
## Agent authentication
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
+35 -13
View File
@@ -21,15 +21,8 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
The daemon collects an eligible idle runtime after two minutes and sweeps every 15 seconds. Only
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
or permission, and have not been activated during the idle window are eligible. `running`,
`initializing`, and `error` agents stay resident. Subagents are considered independently; collection
does not cascade or change parentage.
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
runtime.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
### Cancellation
@@ -58,9 +51,8 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
releases the live runtime and writes `lastStatus: closed` on the still-active record.
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
@@ -80,6 +72,20 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never
received.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
@@ -101,9 +107,11 @@ Agent lifecycle status stays literal: a parent agent is `idle` when its own turn
Workspace status is an aggregate activity signal computed **per `workspaceId`**. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute `running` to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship.
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
## The subagents track
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
One collapsible section of the composer track lane in an agent's pane (`packages/app/src/subagents/track.tsx`), stacked with any other track the agent has. It combines two kinds of children:
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
@@ -117,6 +125,20 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
Provider descriptors may include one compact subtitle. The provider owns its contents and formatting; clients display and truncate it without interpreting provider-specific model, thinking, or usage fields.
### Claude provider subagents: the task protocol
Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `task_updated` / `task_notification` / `task_progress`), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (`subagents/live-source.ts`) and the replay source (`subagents/replay-source.ts`) both translate into one observation vocabulary (`subagents/observation.ts`), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types:
- **Not every announced task 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.
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
+13 -3
View File
@@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1
## Prerequisites (local dev)
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev):
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev):
```bash
mise install # java 21 + android-sdk 21.0 command-line tools
```
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`.
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry.
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
@@ -118,7 +118,17 @@ cd android
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
For a single-ABI APK, pass React Native's architecture property to Gradle:
```bash
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
-PreactNativeArchitectures=arm64-v8a \
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code.
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
+25 -5
View File
@@ -121,6 +121,7 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- `paseo daemon start/stop/restart/status/pair/set-password`
- `paseo chat ls/create/inspect/post/read/wait/delete`
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo script ls/start/stop`
- `paseo loop run/ls/inspect/logs/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
- `paseo heartbeat create/update/delete`
@@ -132,16 +133,20 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
Communicates with the daemon via the same WebSocket protocol as the app.
### `packages/relay` — E2E encrypted relay
### `packages/relay` — Relay transport and E2E encryption
Enables remote access when the daemon is behind a firewall.
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed.
See [SECURITY.md](../SECURITY.md) for the full threat model.
### Paseo Hub
@@ -208,7 +213,9 @@ There is no dedicated welcome message; the server emits a `status` session messa
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
@@ -222,6 +229,11 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
Agent snapshots optionally carry the daemon-owned active turn identity, and turn lifecycle stream events
optionally carry the same `turnId`. New clients use these fields when present and normalize an old daemon's
status once at the directory boundary rather than maintaining a second activity model.
- Terminal subscribe/input/capture commands
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
@@ -246,6 +258,10 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
File downloads keep the existing `FileBegin`/`FileChunk`/`FileEnd` framing and stream 256 KiB chunks
from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before
reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated
messages or transfers.
### Compatibility rules
@@ -283,7 +299,10 @@ initializing → idle ⇄ running
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline sequence allocation is append-only with epochs (each run starts a new epoch). The one
permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted
prompt; it preserves the row's sequence, content, and timestamp. Storage uses sequence numbers for
client-side dedup; the default fetch page is 200 items.
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
@@ -364,6 +383,7 @@ $PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
├── projects/icons/ # Custom project icon images
├── chat/ # Chat rooms
├── schedules/ # Scheduled-agent definitions and runs
├── loops/ # Loop runs and logs
@@ -377,5 +397,5 @@ $PASEO_HOME/
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Electron app spawns daemon as subprocess
2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`).
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
+4
View File
@@ -14,6 +14,10 @@ For testing rules, see [testing.md](testing.md).
- **`interface`** over `type` when both work.
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
## Shell scripts
- Bash scripts always use `#!/usr/bin/env bash`. Never hard-code `/bin/bash` or `/usr/bin/bash`; those paths are not portable to environments such as NixOS.
## Comments and noise
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
+5
View File
@@ -584,6 +584,11 @@ When you launch an agent with an ACP provider:
4. Paseo creates a session and sends prompts through the ACP protocol
5. The agent streams responses, tool calls, and permission requests back over stdout
Every ACP provider exposes an **Auto Accept** toggle. Enable it per session to let Paseo approve
ACP permission requests without surfacing each prompt. If the provider sends no allow option,
Paseo leaves the request for you to answer. Unattended agents enable Auto Accept unless you
explicitly disable it.
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
```json
+83 -32
View File
@@ -4,9 +4,15 @@
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
Creation persists it with the project, and normal boot reconciliation fills it for
older records where the field is absent—there is no migration.
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
@@ -54,7 +60,8 @@ $PASEO_HOME/
│ └── loops.json # All loop records
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
│ ├── workspaces.json # Workspace registry
│ └── icons/ # Host-local custom project icon images
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
@@ -180,9 +187,10 @@ Single file, validated with `PersistedConfigSchema`.
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
mcp: { enabled: boolean, injectIntoAgents: boolean },
git: { maxProcessesPerSecond: number, maxProcessConcurrency: number },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false
auth: { password: string } // bcrypt hash, optional
},
app: {
@@ -227,6 +235,40 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults.
### Git process limits
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
second, and the concurrency limit defaults to `8`:
```json
{
"daemon": {
"git": {
"maxProcessesPerSecond": 64,
"maxProcessConcurrency": 8
}
}
}
```
`maxProcessesPerSecond` limits Git process starts in any one-second interval. The allowance can
start as a burst; it does not wait for earlier processes to exit. `maxProcessConcurrency` limits
the number of Git processes that have started but not exited. Every Git command uses both limits,
including initial workspace reads, filesystem-triggered refreshes, background checks, and explicit
requests.
Environment variables override `config.json`:
| Environment variable | Setting |
| ------------------------------------ | ------------------------ |
| `PASEO_GIT_MAX_PROCESSES_PER_SECOND` | `maxProcessesPerSecond` |
| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` |
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the
daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon.
For a desktop-managed daemon, fully quit and reopen Paseo Desktop.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
@@ -448,16 +490,24 @@ Single file containing an array of all loop records. Writes are direct (not atom
Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
| Field | Type | Description |
| -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `customIconRevision` | `string \| null` | Identifies the host-local custom icon stored under `projects/icons/`. Null means the icon is discovered by scanning the project directory. |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
Uploading a file and pasting a website or image URL are two ways of _acquiring_ the same custom
icon. The client fetches URL imports and sends their bytes through the upload RPC. The daemon never
receives or fetches the URL; it validates the uploaded bytes, stores them, and records a new
`customIconRevision`. Going back to automatic deletes the stored image, as does removing the
project.
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
@@ -475,23 +525,24 @@ workspace together with its owning project.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| Field | Type | Description |
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
+27 -9
View File
@@ -98,7 +98,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
@@ -122,7 +122,21 @@ The whitespace is the design.
---
## 8. Responsiveness
## 8. Alignment
Things align to their glyphs, not to their boxes. A row's leading icon, its title, and the label of the button in its trailing slot sit on the same rails — the ink lines up, not the padding, not the touch target, not the hover background.
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
One row off the rail makes the whole card look unconsidered.
---
## 9. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
@@ -143,7 +157,7 @@ A new list+detail feature copies the settings shell. A new workspace-shaped feat
---
## 9. Copy and voice
## 10. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
@@ -165,7 +179,7 @@ Terminology:
---
## 10. States
## 11. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
@@ -185,9 +199,11 @@ Partial failure (a list mostly fine but one source errored) is a bordered banner
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
Changing state must not move the layout. A row that grows when its badge arrives, a card that reflows when a count resolves, a list that jumps as data streams in — all wrong. Reserve the space the loaded state will need, so the skeleton, the spinner, and the content occupy the same box. A surface that shifts under the user stops feeling calm.
---
## 11. List rows
## 12. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
@@ -203,24 +219,26 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
---
## 12. Status pills and badges
## 13. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
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.
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.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
## 13. Forbidden
## 14. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds and the identity color table are the documented exceptions (§13), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
@@ -233,7 +251,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
---
## 14. Canonical surfaces by pattern
## 15. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+78 -6
View File
@@ -19,8 +19,25 @@ Root checkout dev is intentionally split across terminals:
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development`,
so development-only providers such as Mock Load Test are available. Packaged
desktop launches always force the daemon to production mode.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
## Nix desktop package
The flake exposes `packages.<system>.desktop` on Linux and macOS:
```bash
nix build .#desktop
```
Linux produces the `paseo-desktop` launcher and desktop entry. macOS produces
`Applications/Paseo.app` plus the `paseo-desktop` launcher. Both use the nixpkgs
Electron runtime and the checkout's built daemon, client, and renderer rather
than downloading a published desktop release.
### PASEO_HOME
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
@@ -98,10 +115,21 @@ The iOS simulator shares the Mac's loopback, so `localhost:<port>` reaches the h
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled so
renderer CPU profiles can be captured through CDP. By default it passes
`--remote-debugging-port=0`, so Chromium atomically asks the OS for an available
port and prints the selected DevTools endpoint. Set
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when a QA workflow requires a validated,
fixed port.
Desktop dev also scopes Electron `userData` to the current dev root. This prevents
desktop-only environment inherited by terminals opened inside Paseo from coupling
a new worktree instance to the parent desktop instance's profile or single-instance
lock.
Electron remains in the desktop dev runner's process group. Closing or stopping the
workspace-script terminal must terminate Electron with Metro; detaching Electron
leaves an orphan holding the worktree's single-instance lock and broken output pipes.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with:
@@ -111,8 +139,9 @@ npm run verify:electron-cdp --workspace=@getpaseo/desktop
```
The verifier reads the same `EXPO_PORT` and
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set both when
testing an isolated instance on non-default ports.
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set an
explicit remote-debugging port for verifier runs, and set both when testing an
isolated instance on non-default ports.
When running a dedicated Electron QA instance against a non-default Expo port, set
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
@@ -215,6 +244,27 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Git process pressure
If Git refreshes consume too much CPU, disk, or antivirus capacity, especially on Windows, reduce
the daemon-global Git process limits in `$PASEO_HOME/config.json`:
```json
{
"daemon": {
"git": {
"maxProcessesPerSecond": 5,
"maxProcessConcurrency": 4
}
}
}
```
Restart the daemon with `paseo daemon restart`. If Paseo Desktop manages the daemon, fully quit and
reopen the desktop app. Lower values reduce machine pressure but make Git-backed workspace state and
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
semantics, and environment-variable overrides.
### Agent Tool Catalog Measurement
Measure the MCP `tools/list` payload that Paseo injects into agents with:
@@ -228,6 +278,14 @@ tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
for machine-readable output.
## Worktree starting refs
The New workspace branch picker treats local and `origin/*` refs as distinct starting points when
they differ. Local `main` is sent as `refs/heads/main`; remote `origin/main` is sent as
`refs/remotes/origin/main`. Matching local/remote refs collapse to one local row. Unqualified refs
from older clients resolve local-first, then fall back to `origin`. Worktrees inherit committed Git
state only; uncommitted source-checkout changes are not copied.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
@@ -242,6 +300,14 @@ commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
Because the shell differs per platform, a lifecycle command that must run
everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR`
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
that reads what it needs from `process.env` and invoke it as
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
```json
{
"worktree": {
@@ -390,6 +456,7 @@ npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
@@ -400,6 +467,11 @@ Use `--host <host:port>` to point the CLI at a different daemon:
npm run cli -- --host localhost:7777 ls -a
```
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
default; pass `--server <server-id>` when targeting another server.
## Agent state
Agent data lives at:
+3 -2
View File
@@ -176,8 +176,9 @@ IPs and `localhost` are allowed by default.
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
- Prefer HTTPS at the reverse proxy for direct browser access.
- Use the Paseo relay for untrusted networks or mobile access when you do not
want to expose the daemon port directly.
- Use the [official Paseo relay](https://github.com/getpaseo/paseo-relay) for
untrusted networks or mobile access when you do not want to expose the daemon
port directly.
- The container is the isolation boundary for agents. Agents can read and write
whatever you mount into `/workspace` and whatever credentials you place in
`/home/paseo`.
+14
View File
@@ -73,6 +73,20 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
targets.
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
directly to the workspace with the agent open intent.
- Agent URLs carry only `serverId` and `agentId`. Route them through
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
the agent's workspace from the host, and then opens the agent there.
Both paths converge on `navigateToAgent()`. Do not make notification routing
guess a workspace, and do not add a workspace to the stable agent URL format.
## Params
Required dynamic params belong to the matched route.
+30
View File
@@ -78,6 +78,29 @@ ordinary portals regardless of `z-index`, which would hide app toasts and
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
lets tooltip portals paint above both.
The shared overlay scale is relative for interactive surfaces: a base floating
panel is below a base modal, while a floating panel rendered from inside a modal
inherits that modal's layer and paints above it. Wrap portal content in
`OverlayLayerProvider`; do not assign one global menu z-index. Desktop web
comboboxes must use `overlay-root` too. Rendering them through React Native
Web's `<Modal>` puts them in the browser top layer, where no ordinary modal
portal can cover them.
Painting and keyboard ownership use the same relative layer model. Register
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
highest painted scope alone receives overlay keys, traps focus, and restores
focus when it closes. Do not add component-local global Escape listeners: two
stacked overlays would both close on one keypress.
If an overlay is rendered by a global host rather than beneath its opener in
the React tree, carry the opener's current layer through the host store and
restore it with `OverlayLayerProvider`. Otherwise painting and keyboard
ownership silently reset at the app root. When the opener is a global keyboard
action and has no component context to carry, resolve the host layer with
`useGlobalWebOverlayLayer` on its closed-to-open transition. It captures the
current top registered layer before the new host joins the stack; do not give a
global dialog a fixed root-derived modal layer.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
A Portal escapes Android's hit-test, but it also escapes two things you were
@@ -129,6 +152,13 @@ lockstep, no re-measurement needed. Do not call
can briefly report a stale nonzero height with closed progress, and the shared
provider is where that is normalized.
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
interactive move, but not at the terminal event, so JS contention can otherwise
leave the last height/progress pair stuck in either the open or closed state.
Keep that terminal reconciliation on the UI thread; a later focus or blur must
not be required to repair the offset.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.
+7 -4
View File
@@ -2,7 +2,7 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
@@ -10,6 +10,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Host badge** — The sidebar pill naming which host a workspace lives on. Only drawn once the visible sidebar spans more than one host. Its name, color, and display mode (**Name** / **Icon only** / **Hidden**) are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. UI: the "Appearance" group on a host's settings page. Code: `HostBadge` (`packages/app/src/components/sidebar/host-badge.tsx`), `selectHostBadges` (`packages/app/src/hosts/appearance.ts`). Don't confuse with **Status pill**, which reports state rather than identity.
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
@@ -17,7 +18,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
@@ -29,7 +30,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **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.
- **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. The agent's **Heartbeats track** lists the ones still on a cadence for that agent on that host; opening a row goes to it on Schedules (`/schedules?serverId=…&scheduleId=…`, keyed by both because schedule ids are host-local). Paused, completed, and expired heartbeats stay on Schedules and leave the track. A host without `heartbeatWorkspaceActivity` still runs the prompts but does not report them as workspace activity, so the track links to host settings instead of contradicting the workspace status.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
@@ -37,10 +38,12 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Heartbeats track**, **Subagents track**. Tracks above the input are sections of one card so several can stack without either one owning the seam with the composer. Code: `ComposerTracks` / `ComposerTrackSection` (`packages/app/src/composer/tracks.tsx`), queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `HeartbeatsTrack` (`packages/app/src/heartbeats/track.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
- **Product discussion** — Where non-bug product talk goes: [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) and `#product` in Discord. Covers workflows, features, and product direction. The container term; the ask inside it is always a **workflow** — what you're doing, how you do it today, where Paseo gets in the way. Issues are bugs only. Forbidden: "Feature request" as a Paseo surface, label, or invitation; the phrase is only used when answering someone who arrived with it. Don't invent "feature discussion", "idea", or "workflow thread" as synonyms. See [CONTRIBUTING.md](../CONTRIBUTING.md).
## Inconsistencies (documented, not papered over)
+19 -2
View File
@@ -45,8 +45,25 @@ replays the original prompt. A duplicate create returns the existing agent witho
turn.
Hub creates use the same agent creation path as trusted clients. They may select any existing
worktree target shape and may request `autoArchive`. Worktree creation and terminal auto-archive use
the shared workspace-aware lifecycle policy; Hub does not have a second launch or cleanup path.
worktree target shape and carry optional MCP server configuration for the agent session. The daemon
keeps that configuration in its private agent record so provider sessions can recover after a
restart; neither ordinary client snapshots and updates nor Hub projections expose session
configuration. Execution completion policy remains outside the daemon: a completed agent turn does
not imply that the Hub execution is terminal.
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both
actions are idempotent and continue to resolve from stored ownership after daemon restart.
If no execution exists for that authenticated daemon and execution ID, interrupt and archive return
success because the requested stopped or archived state already holds. An execution owned by another
daemon is indistinguishable from a missing execution and is never exposed or affected.
Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent.
When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the
workspace through the shared workspace archive service, so the backing directory is removed only
after its final active workspace reference disappears. Local and shared checkouts archive only the
execution-owned agent.
## Disconnect and revocation
+4
View File
@@ -70,6 +70,10 @@ definition, no longer eligible to begin.
- Callers request semantic targets through `panel-store`; they never write shared values.
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
- A focused interactive surface may block panel-opening gestures through
`useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and
unregister when the surface is hidden or unfocused. The blocker never disables gestures that close
an already-open panel.
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
so its injected `collapsable={false}` reaches Android/Fabric.
+42 -1
View File
@@ -1,6 +1,47 @@
# Mobile Testing
## Maestro
## Agent Device
Agent Device `.ad` scripts are the primary mobile E2E format. An agent discovers a working flow interactively, saves the successful commands, then the replay runner executes the same typed plan locally or in CI.
Record a flow while driving the app normally:
```bash
agent-device open sh.paseo.debug \
--platform ios \
--session terminal-author \
--save-script ./packages/app/e2e/mobile/agent-device/terminal.ios.ad
agent-device snapshot -i --session terminal-author
agent-device press 'id="workspace-header-menu-trigger"' --session terminal-author
# Continue the flow and verify its result with wait/get/is/find.
agent-device close --session terminal-author
```
`close` writes the script. Keep selectors based on stable app IDs. Keep assertions as `wait`, `get`, `is`, or `find` commands; screenshots are evidence, not assertions.
Run the Paseo mobile suite:
```bash
npm run test:e2e:mobile
```
The runner uses an isolated Agent Device state directory, verifies or starts Metro for this checkout, prewarms the iOS runner, discovers each script's platform from its `context` header, and cleans its sessions, runner lease, daemon, and any Metro process it started. Attempt results, timings, logs, and failure artifacts go under `.dev/agent-device-artifacts`.
Set `PASEO_MOBILE_E2E_METRO_PORT` when this worktree already has Metro on a non-default port:
```bash
PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
```
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
## Maestro compatibility
Existing Maestro flows live in `packages/app/maestro/`. Agent Device can execute its supported subset with `agent-device test <path> --maestro`, which provides a migration path while `.ad` coverage replaces these flows.
### Existing flows
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
+2 -1
View File
@@ -43,7 +43,8 @@ This architecture means:
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when you close the app
- Agents keep running when a client disconnects — the daemon owns them, not the client
- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected
## Target user
+59
View File
@@ -0,0 +1,59 @@
# Protocol Compatibility
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
Two contracts follow from it.
## The protocol contract: always compatible
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
- New fields are `.optional()` with a sensible default.
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
- Wire schemas are pure structural declarations. No `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas — normalization happens in an explicit pass after validation. The reason is in [protocol-validation.md](protocol-validation.md): inbound validators are generated, and the generator only compiles pure schemas.
- Plain `z.union()` is forbidden when every branch shares a literal tag. Use `z.discriminatedUnion()`.
- `.default()` belongs on primitive leaves only, never on item schemas inside large arrays or big inbound containers.
Two questions to ask before you commit a schema change:
1. Does a six-month-old app still parse this message?
2. Does a six-month-old daemon still send something this app accepts?
If you can't answer both with yes, the change isn't done.
Schemas live in `packages/protocol/src/messages.ts`. New RPC names follow [rpc-namespacing.md](rpc-namespacing.md).
## The feature contract: per feature, gated once
Features don't have to work across versions. A new feature usually needs a new daemon capability, and old daemons don't have it.
The app checks for the capability and either runs the feature or tells the user to update the host.
- **No fallback paths.** Don't build a degraded version of the feature for old daemons. Don't fan out across legacy RPCs to simulate a capability that isn't there. The user updates or doesn't get the feature.
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
```ts
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
```
`rg "COMPAT\("` is the full cleanup backlog, so:
- One tag per shim, at the site that has to be deleted.
- Give it a name, a version, and a removal condition or date. Six months out is the usual default.
- Never bury compatibility in an untagged `??` fallback or an optional-chain tunnel. Untagged back-compat never gets removed, because nobody can find it.
When a tag's condition is met, delete the shim and the tag in the same change.
## QA
Tests don't fully cover compatibility. If you touched `packages/protocol`, say in the pull request why an older app still parses your message and why an older daemon still satisfies your app. See [qa.md](qa.md).
+16 -4
View File
@@ -12,6 +12,8 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
ACP permission options are rendered as ordered actions and Paseo returns the selected option's exact `optionId`. Agents can therefore encode a single-choice question as multiple options of the same allow kind. Auto-accept does not resolve those chooser requests; they always wait for the user.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
@@ -34,7 +36,7 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
@@ -42,11 +44,13 @@ OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add`
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
@@ -458,3 +462,11 @@ Tests use `isProviderAvailable(provider)` to skip when the binary or credentials
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
**Session-scoped cancellation needs a stop boundary inside the provider.** Some agents cancel the whole session rather than one turn — OpenCode's `session.abort` is the example. A cancel that is still in flight when the next run starts will kill that replacement run, which is what makes "stop, then immediately prompt again" (`replaceRunning`, `notifyOnFinish` wakes, schedules) flaky. Own this in the provider session, not in `AgentManager`:
- Model the stop as an explicit `stopping` turn-state variant carrying the canceled run's terminal and the cancellation the caller is still owed. Pressing Stop again retries the stop already in progress rather than opening a second one; never fire a detached retry, it will outlive its boundary.
- **Scope the cancel settlement the way the provider scopes the cancel.** If cancellation is session-scoped, so is its settlement: track it on the session, accumulating every request issued, and let it outlive the stop that issued it. A request still in flight lands on the runner whenever the server gets to it — however many stops have come and gone since. Scoping it to the current stop looks right and quietly drops older requests from the gate. Only the newest may hold the gate closed, since recovering from a failed cancel is what pressing Stop again is for.
- Gate the operations **the daemon issues** (prompt, slash command, summarize) on both the terminal and cancel settlement. Permission and question responses are not runner operations and must stay outside the gate, or an auto-approve deadlocks the stop. Runs the _provider_ starts on its own — plugin or autonomous wakes — are observed, not gated: the daemon does not choose when they begin, and holding their events back does not protect them from a cancel already in flight, it only hides a run that may already be dead.
- Fail closed: if the cancel never succeeded you never proved the run stopped, so refuse new runs until the next Stop issues a fresh cancel. `AgentManager` already turns a rejected `interrupt()` into a refused cancel.
- Suppress the canceled run's residue only until its authoritative terminal. Anything the provider publishes after that terminal is a new run by construction and must take the normal live path — buffering it and replaying it later is how autonomous/plugin wakes get lost.
+77
View File
@@ -0,0 +1,77 @@
# QA
QA is the main bottleneck of Paseo's product development.
The bar is four questions:
1. Does it work well?
2. Does it regress anything else?
3. Was it tested on every platform it affects?
4. Is there automated coverage, and is that coverage real?
Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed.
## Evidence
Evidence is something someone else can look at:
- The commands you ran, pasted with their output
- The tests you added and their results
- Before and after screenshots
- A video of the interaction
- Logs, requests, responses
Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it.
## Does it work well
A feature that works but stutters, shifts the layout, or misaligns by two pixels is not done.
For UI, that means matching the design system in visuals, in interaction, and in performance. [design.md](design.md) is the reference.
The two things that go wrong most often:
- **Layout shift.** Content that jumps as data arrives, a row that resizes when a badge appears, a panel that reflows on first render. Watch the change on a slow connection and on first load, not just after everything is warm.
- **Alignment.** Things align to their glyphs, not to their touch targets. See the alignment rules in [design.md](design.md).
## Does it regress anything else
Paseo is composable by design, which means your change sits next to features you didn't touch. Open the surfaces around it. A change to the agent list affects archive, subagents, and tabs; a change to git actions affects worktrees and the checkout flow.
Performance is part of this. The app is Expo React Native, not a web app in a native shell. You are not writing CSS, styles resolve differently, and performance characteristics differ per platform. What feels instant in a desktop dev build can be visibly slow on a phone.
If your change touches a hot path such as the terminal, the message list, or git polling, submit before and after numbers. [terminal-performance.md](terminal-performance.md) has the terminal pipeline and its benchmarks, and [development.md](development.md) covers renderer and React profiling.
## Every platform it affects
Your code does not only run on the platform you tested it on. The same app ships to iOS, Android, browser web, and Electron on macOS, Windows, and Linux, and the daemon runs on all three desktop operating systems plus Docker.
You aren't expected to own every device. You are expected to say what you covered:
| Platform | Tested | Notes |
| --------------- | ------ | ----- |
| iOS | | |
| Android | | |
| Web | | |
| Desktop macOS | | |
| Desktop Windows | | |
| Desktop Linux | | |
Install what you reasonably can. An iOS simulator and an Android emulator cover most of the gap on a single machine, see [development.md](development.md) and [android.md](android.md).
For the rules about which code runs where, read the platform gating section in [CLAUDE.md](../CLAUDE.md). The recurring traps have their own docs: [hover.md](hover.md), [unistyles.md](unistyles.md), [floating-panels.md](floating-panels.md), [mobile-panels.md](mobile-panels.md), [expo-router.md](expo-router.md).
App and daemon versions also drift, in both directions. That has its own contract: [protocol-compatibility.md](protocol-compatibility.md).
## Automated coverage that means something
Tests are evidence only when they exercise the real thing.
- A bug fix needs a regression test that fails on the old code for the reported reason.
- A feature needs tests that go through the same interface a user or a caller uses.
- Web flows need an actual Playwright run, not a mocked-out approximation of one.
- Agent provider work needs manual verification against the real provider by someone who can tell that it worked. A fixture and a mock prove the fixture parses, nothing more.
Tests that mock away the behavior, assert on internals, or pass against the broken code claim coverage that isn't there.
[testing.md](testing.md) is the standard, including how to run suites without freezing your machine. For driving a real daemon in a test, see [ad-hoc-daemon-testing.md](ad-hoc-daemon-testing.md). For mobile flows, [mobile-testing.md](mobile-testing.md). For Electron screenshots, [browser-capture-harness.md](browser-capture-harness.md).
+25 -1
View File
@@ -38,6 +38,14 @@ 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.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
@@ -76,9 +84,23 @@ 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.
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
republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
for package in highlight relay protocol client server cli; do
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
done
```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
@@ -92,6 +114,7 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
@@ -506,6 +529,7 @@ Betas are checkpoints along the way; the entry is the single record for the jump
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `Release Mobile` workflow for the same tag is green
+35
View File
@@ -26,6 +26,18 @@ dev--feature-auth--miniweb.localhost
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Managing workspace scripts
Configured `paseo.json` scripts can be managed without addressing their backing terminal directly:
```bash
paseo script ls [--cwd <path> | --workspace <workspace-id>]
paseo script start <name> [--cwd <path> | --workspace <workspace-id>]
paseo script stop <name> [--cwd <path> | --workspace <workspace-id>]
```
The commands return the same script metadata shown by the workspace: lifecycle, service port, proxy URLs, health, exit code, and supervised terminal ID. `stop` terminates the managed terminal rather than only removing the proxy route, so normal script lifecycle cleanup remains authoritative. MCP exposes matching `list_workspace_scripts`, `start_workspace_script`, and `stop_workspace_script` tools; those require an explicit workspace ID.
## Configuration
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
@@ -95,6 +107,29 @@ server {
}
```
Nginx's `$host` drops the port. If you terminate on a non-default port, use `$http_host` instead so the port survives — that is what "forwards the `Host` header unchanged" means here.
## Forwarded headers
Paseo sets these when it forwards a request to a workspace service:
| Header | Value |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Forwarded-Host` | The `Host` header verbatim, including the port when the client used one |
| `X-Forwarded-Proto` | The request scheme (`http` on the WebSocket upgrade path) |
| `X-Forwarded-For` | The immediate peer address. Replaces any existing chain, so behind your own reverse proxy this is the proxy's address, not the client's |
| `X-Forwarded-Port` | The port from the `Host` header when it has one, otherwise whatever your proxy already set |
`X-Forwarded-Port` follows the same trust rule as `X-Forwarded-Host`: the authority Paseo observed wins. When the `Host` header carries a port, that port is reported and replaces any inbound `X-Forwarded-Port`, so a client cannot forge one. When `Host` carries no port there is nothing to observe, so a value your reverse proxy set survives untouched — that is the case where nginx's `$host` drops the port and `X-Forwarded-Port` is the only source. Paseo never derives the port from the scheme. Any other `X-Forwarded-*` header your proxy sends is passed through untouched.
Services that build absolute URLs should prefer `Host` or `X-Forwarded-Host`.
### The forwarded authority is not authenticated
Route lookup normalizes the port away before matching a service hostname, so a client can address the daemon with any port in `Host` and still reach the service. That port is what lands in `X-Forwarded-Host` and `X-Forwarded-Port`. Paseo also does not check whether an inbound `X-Forwarded-Port` came from a proxy in `trustedProxies` — when `Host` carries no port, a client-supplied value is passed through.
Treat the forwarded authority as client-influenced input. A service that builds password reset links, absolute redirects, or cached URLs from it should pin its own public origin in configuration rather than deriving one from request headers. This is not specific to `X-Forwarded-Port`: the `Host` header has always carried a client-chosen port.
## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
+8 -2
View File
@@ -25,16 +25,22 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
## Measuring
- **Node-only benchmark (fast iteration, server pipeline):** `npx tsx scripts/benchmark-terminal-latency.ts`. Boots an isolated daemon (fresh `PASEO_HOME`, random port — never 6767), measures echo latency percentiles, burst jitter, and snapshot counts under ramped mock-agent load. Writes JSON to `/tmp/paseo-terminal-bench/`. Healthy numbers (2026-06): echo p50 ~2.3ms, p95 ~3.3ms, a 2MB burst fully streamed with `snap=0`.
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` —
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
`packages/app/e2e/browser/terminal-performance.spec.ts` and `packages/app/e2e/browser/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age,
queue wait, execution time, failures, timeouts, and top operations),
`git.workspaceService` (daemon-global Git observer ownership), and per-session workspace Git
subscription totals under `runtime`. Queue wait and execution time are separate because the Git
command timeout begins only after a command acquires a limiter slot.
## Known remaining contention (follow-up candidates)
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread.
- Relay-attached clients pay pure-JS tweetnacl encryption + base64 per frame on the daemon main loop (`packages/relay/src/encrypted-channel.ts`).
- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames.
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
+17 -8
View File
@@ -124,14 +124,14 @@ PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
npm run build:desktop -- --publish never --linux --x64 --dir
```
### Browser tab bridge regression
### Desktop browser regression
The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
The desktop browser E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check:
Run it locally with the same command owned by the Ubuntu `desktop-tests` required check:
```bash
npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
```
## Test organization
@@ -154,7 +154,7 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Browser Playwright specs live in `packages/app/e2e/browser/`. Desktop Playwright and real-Electron E2E live in `packages/desktop/e2e/`. Harness code shared by both suites lives in `packages/app/e2e/support/`; neither suite may place specs there. App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default browser project ignores that suffix so CI does not need provider credentials.
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
@@ -175,9 +175,18 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
- Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence.
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
## Pull-request test routing
PR checks are routed by the behavior each suite proves, using `.github/ci-paths.yml`. A package does not inherit every test suite of its runtime consumers: app changes do not run CLI or Electron-wrapper tests, and protocol changes do not run every package that imports the protocol. Cross-package static compatibility belongs to `typecheck`; full integration coverage runs after merge on main and in manual CI runs.
Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name.
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer, real-Electron, and packaged-app coverage together in the Ubuntu leg. CLI-side Hub changes select one focused test inside the existing required server jobs. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
## Agent authentication in tests
+116 -14
View File
@@ -5,9 +5,11 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is:
The invariants are:
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
> A continuously subscribed client applies every committed row in order. Opening or resuming an
> agent establishes the daemon's current tail in one bounded request, with older history reachable
> through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
@@ -25,17 +27,26 @@ Client heartbeat reports presence:
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
## Catch-up is paged but complete
## Gap recovery is paged but complete
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon
responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap
recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
user-driven by scrolling upward.
Reaching the history-start threshold loads one older page and preserves the visible content anchor.
Cursor progress does not trigger another page. The user must leave and return to the threshold unless
the anchored page still leaves the viewport at history start, as with short or compacted content; in
that case pagination continues as one loading operation until the page fills the viewport or history
is exhausted.
## Durable item anchors
@@ -47,9 +58,21 @@ The daemon validates that the epoch is current and the exact source sequence sti
## Resume behavior
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
Opening, reconnecting, or revisiting after selective-delivery grace fetches the latest tail page.
Focus alone does not mutate timeline state; the tail response is compared with the local
authoritative range first.
When a client resumes without a cursor, it fetches the latest tail page.
- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
that cursor are applied. Already-covered rows are not replayed.
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
local submissions; it never retains two discontiguous canonical ranges.
The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
history start; a response requested from a pre-replacement range is stale and is discarded.
## Client replica lifetime
@@ -61,6 +84,24 @@ recomposition while the runtime still owns the same directory snapshot and timel
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
older-history availability, authority status, or sync generation because those facts would describe
the complete source dataset rather than the truncated display dataset.
Restoring that cache produces a painted timeline: the items may render immediately, but the first
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
items, range, and older-history availability. Live rows received between cache paint and that tail
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
reconciled with the authoritative tail and subsequent catch-up.
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
replacement keeps only positioned rows newer than the page it installs, while unresolved local
user presentations identify themselves by having client identity without a timeline position. This
prevents a page from duplicating rows it already covers without coupling display continuity to the
shorter-lived submission registry. Unreconciled local presentations are not persisted in the durable
replica cache.
## Selective and legacy delivery
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
@@ -87,14 +128,75 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
where the user submitted it. Other canonical rows are applied after the already-present timeline
instead of relocating visible user messages around newly fetched history.
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
send-now, and the automatic queue drain in `HostRuntime` — goes through
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
footer, and permanently drops attachments because the daemon does not echo them back.
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
limited to the dated compatibility path for daemon timelines created before that field existed.
A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation:
its stable identity, text, timestamp, images, and attachments do not change when the provider
acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape.
The transaction registry records two independent settlement facts: canonical acknowledgement and RPC
settlement. Canonical acknowledgement retires optimistic activity immediately. When both facts are
known, whichever arrives second deletes the record. A canonical acknowledgement that arrives first
prevents a later transport error from rolling back a prompt already observed.
The daemon's accepted response waits for the correlated run start and guarantees that the canonical
submitted row has been recorded. It publishes the accepted turn's liveness before that row, so the
client applies authoritative activity before canonical acknowledgement retires optimistic activity.
Timeline render batching does not delay lifecycle application. Directory status never settles a
submission. Overlapping sends settle independently rather than collapsing to one newest pending
message.
Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted
prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
row is recorded before handler output. The app tracks submission transactions only for hosts with this
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
rejection.
Turn activity has one client-side replica. Lifecycle events can attach `turnId`, and agent snapshots can
expose `activeTurn: { turnId, startedAt } | null`. An identified terminal cannot close a different
identified turn; an unnamed legacy terminal can close the current turn. Snapshots, stream events,
cancellation requests, and every visible surface update the same per-agent record. The snapshot covers
both user-started foreground turns and autonomous provider turns;
foreground control ownership remains a separate daemon concern. Cancellation request identity is stored
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time
comes only from turn liveness, never from submission records or whichever timeline rows happen to be
mounted.
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
the wire. A correlated provider echo records the provider's native identity internally without
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
the provider boundary. Daemon-handled prompts follow the same identity rule.
Content matching is limited to the dated compatibility path for daemon timelines created before
that field existed. Canonical ingestion may match only an explicit unreconciled local candidate;
the draft-create handoff is the one boundary that also permits the legacy canonical twin to have
arrived first. Generic reducers and consumers do not reimplement message identity matching.
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
rows because a provider may never echo them. A known epoch change or rewind replaces history and
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
acknowledged by the provider, and no other local row, crosses that destructive boundary. A cold
reset without an existing epoch is destructive because the client has no continuity anchor.
Tail rows are positioned history, so an unmatched local presentation is appended after the
canonical replacement rather than ordered by timestamps from different machines. The head is a
live overlay: cursorless items stay there during continuity replacement until canonical positions
arrive, while a destructive replacement retains only active submission transactions.
Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and
payload while taking the canonical row's ordered position. If a live assistant head is the
canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes.
## Relevant code
+7 -2
View File
@@ -129,7 +129,12 @@ This mistake once produced tens of thousands of warnings from retained sidebar r
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the `style` prop Unistyles registers. How badly that bites depends on who owns the component:
- **A remapped React Native `ScrollView`** still picks the style up — the Babel plugin rewrites the import and the factory tracks `contentContainerStyle` alongside `style`. The failure mode here is staleness: on first mount it can paint with the theme that was active at mount time and never repaint. The upstream tutorial calls this out in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
- **A third-party scroller** — `BottomSheetScrollView`, `FlashList`, anything Unistyles never rewrote — drops the style entirely on web. Nothing throws and the value looks right in JS, but the class Unistyles emitted is never applied, so the padding, gap, or background is simply absent. `AdaptiveModalSheet` shipped its compact sheet content flush to the screen edges for exactly this reason while the desktop card, which uses a real `ScrollView`, looked correct.
The second case is why the wrapper-`View` fix below is not optional styling advice. A themed inset belongs on a `View` you own; a third-party scroller only gets theme-free layout.
Avoid this pattern when the style depends on the theme:
@@ -144,7 +149,7 @@ const styles = StyleSheet.create((theme) => ({
}));
```
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy reports the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
+11 -6
View File
@@ -26,17 +26,22 @@
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
isLinux = nixpkgs.lib.elem system [
"x86_64-linux"
"aarch64-linux"
versionParts = pkgs.lib.splitString "." paseo.version;
sourceRevision = if self ? revCount && self.revCount != null then self.revCount else 0;
buildRevision = sourceRevision - (sourceRevision / 10000) * 10000;
desktopBuildVersion = pkgs.lib.concatStringsSep "." [
(builtins.elemAt versionParts 0)
(builtins.elemAt versionParts 1)
(toString buildRevision)
];
in
{
default = paseo;
paseo = paseo;
}
// nixpkgs.lib.optionalAttrs isLinux {
desktop = pkgs.callPackage ./nix/desktop-package.nix { inherit paseo; };
desktop = pkgs.callPackage ./nix/desktop-package.nix {
inherit paseo;
buildVersion = desktopBuildVersion;
};
}
);
+155 -54
View File
@@ -5,34 +5,48 @@
nodejs_22,
python3,
makeWrapper,
autoPatchelfHook,
copyDesktopItems,
makeDesktopItem,
electron,
libuv,
buildVersion,
# Reuse the daemon's prebuilt npm-deps FOD. Same lockfile, same content —
# without this, the desktop drv produces a separately-named store path
# (`paseo-desktop-<v>-npm-deps`) and refetches the entire registry. Override
# the upstream hash via `paseo.override { npmDepsHash = "..."; }`.
paseo,
}:
buildNpmPackage rec {
buildNpmPackage {
pname = "paseo-desktop";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter =
path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
filter = path: type: let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude mobile-only platform code (we only need the web/electron build)
!(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
# Website is unrelated to the desktop app
&& !(lib.hasPrefix "/packages/website" relPath)
# Documentation, CI definitions and agent/editor configuration. None of
# these reach the build, but every one of them is part of `src`, so a
# docs-only or workflow-only commit currently invalidates the whole
# desktop derivation and pays for a full Expo export to produce a
# byte-identical result.
&& !(lib.hasPrefix "/docs" relPath)
&& !(lib.hasPrefix "/.github" relPath)
&& !(lib.hasPrefix "/.agents" relPath)
&& !(lib.hasPrefix "/.claude" relPath)
&& !(lib.hasPrefix "/.codex" relPath)
&& !(lib.hasPrefix "/docker" relPath)
# Top-level prose only (README, CHANGELOG, AGENTS...). Deeper markdown is
# not necessarily documentation: skills/*/SKILL.md is a runtime file the
# installPhase copies into the output.
&& builtins.match "/[^/]+\\.md" relPath == null
# Test fixtures and build artifacts
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
@@ -48,16 +62,23 @@ buildNpmPackage rec {
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
npmRebuildFlags = ["--ignore-scripts"];
nativeBuildInputs = [
python3 # for node-gyp (node-pty)
makeWrapper
copyDesktopItems
nativeBuildInputs =
[
python3 # for node-gyp (node-pty)
]
++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
makeWrapper
copyDesktopItems
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
libuv
stdenv.cc.cc.lib # libstdc++ for sherpa-onnx prebuilt binaries
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
dontNpmBuild = true;
env = {
@@ -82,61 +103,125 @@ buildNpmPackage rec {
# Expo web export for the Electron renderer
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
# Desktop main process (tsc only — NOT electron-builder)
# Desktop main process
npm run build:main --workspace=@getpaseo/desktop
${lib.optionalString stdenv.hostPlatform.isDarwin ''
# Let electron-builder create the native bundle layout (including helper
# app names and bundle identifiers), but source Electron from nixpkgs
# instead of downloading a release at build time. electron-builder edits
# the copied helper plists, so stage a writable distribution rather than
# pointing it directly at the read-only Nix store.
electron_dist="$NIX_BUILD_TOP/electron-dist"
mkdir -p "$electron_dist"
cp -R ${electron}/Applications/Electron.app "$electron_dist/"
chmod -R u+w "$electron_dist/Electron.app"
(
cd packages/desktop
# The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \
../../node_modules/.bin/electron-builder \
--config electron-builder.yml \
--dir \
--mac \
--publish never \
--config.electronDist="$electron_dist" \
--config.buildVersion=${lib.escapeShellArg buildVersion} \
--config.mac.identity=null \
--config.mac.hardenedRuntime=false \
--config.mac.notarize=false
)
''}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/share/paseo-desktop $out/bin
mkdir -p $out/bin
# Preserve the monorepo layout so main.js's dev-mode path resolution
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
# without patching: invoked unpackaged via `electron path/to/main.js`,
# `app.isPackaged` is false, so these relative paths are used.
#
# Copy the entire packages/ tree (not just built artifacts) because npm
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
# Missing any workspace package leaves dangling symlinks and fails the
# noBrokenSymlinks output check. The cleanSourceWith filter above already
# drops the big platform-specific things (android/ios, website, tests).
cp package.json $out/share/paseo-desktop/
cp -a packages $out/share/paseo-desktop/
cp -a node_modules $out/share/paseo-desktop/
${lib.optionalString stdenv.hostPlatform.isLinux ''
mkdir -p $out/share/paseo-desktop
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Materialize only the desktop and daemon runtime graphs. Copying the
# complete monorepo used to ship every build-time dependency (including
# Electron, Expo tooling, and cross-platform builder binaries), making the
# desktop output larger than 2 GiB.
PASEO_TRACE_DESKTOP=1 node scripts/trace-daemon.mjs > desktop-files.txt
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
while IFS= read -r path; do
[ -z "$path" ] && continue
mkdir -p "$out/share/paseo-desktop/$(dirname "$path")"
cp -a "$path" "$out/share/paseo-desktop/$path"
done < desktop-files.txt
# Launcher wraps nixpkgs electron.
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
# `security.wrappers` via a NixOS module for users who want the sandbox.
#
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
# exist here). Point it at the `paseo://` protocol handler instead, which
# serves from `__dirname/../../app/dist` (our install layout matches).
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
--add-flags "--no-sandbox" \
--set EXPO_DEV_URL "paseo://app/"
# Keep the same unpackaged monorepo layout expected by main.js.
cp package.json $out/share/paseo-desktop/
mkdir -p $out/share/paseo-desktop/packages/app
cp -a packages/app/dist $out/share/paseo-desktop/packages/app/
copyDesktopItems
for runtime_path in \
packages/desktop/dist/main.js \
packages/desktop/dist/preload.js \
packages/desktop/dist/features/browser-keyboard/guest-preload.js \
packages/desktop/package.json; do
if [ ! -e "$out/share/paseo-desktop/$runtime_path" ]; then
echo "desktop runtime trace omitted $runtime_path" >&2
exit 1
fi
done
if [ -e $out/share/paseo-desktop/node_modules/electron ]; then
echo "desktop runtime trace included npm Electron" >&2
exit 1
fi
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
# Electron derives Wayland's toplevel app_id from the package name in the
# app root it launches. Point it at a one-file app named "paseo-desktop"
# so shells can match the window to the desktop entry and hicolor icon.
mkdir -p $out/share/paseo-desktop/electron-app
printf '%s\n' "{ \"name\": \"paseo-desktop\", \"version\": \"$version\", \"main\": \"index.js\" }" \
> $out/share/paseo-desktop/electron-app/package.json
printf '%s\n' 'require("../packages/desktop/dist/main.js");' \
> $out/share/paseo-desktop/electron-app/index.js
# Chromium's setuid sandbox cannot live in the immutable Nix store.
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/electron-app" \
--add-flags "--no-sandbox" \
--add-flags "--class=paseo-desktop" \
--set EXPO_DEV_URL "paseo://app/" \
--set CHROME_DESKTOP "paseo-desktop.desktop"
copyDesktopItems
''}
${lib.optionalString stdenv.hostPlatform.isDarwin ''
app="$(find packages/desktop/release -maxdepth 3 -type d -name Paseo.app -print -quit)"
if [ -z "$app" ]; then
echo "electron-builder did not produce Paseo.app" >&2
exit 1
fi
mkdir -p "$out/Applications"
cp -R "$app" "$out/Applications/Paseo.app"
ln -s ../Applications/Paseo.app/Contents/MacOS/Paseo "$out/bin/paseo-desktop"
''}
runHook postInstall
'';
desktopItems = [
desktopItems = lib.optionals stdenv.hostPlatform.isLinux [
(makeDesktopItem {
name = "paseo-desktop";
desktopName = "Paseo";
@@ -144,8 +229,24 @@ buildNpmPackage rec {
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = ["Development"];
startupWMClass = "paseo-desktop";
})
# Hidden alias entry. Which of the two names Electron ends up publishing as
# the Wayland app_id depends on the Electron version: 41 uses the app-root
# package.json `name` ("paseo-desktop"), 38 uses the runtime app name that
# main.ts sets ("Paseo"). Ship a NoDisplay entry for the second spelling so
# the icon resolves either way without a duplicate launcher item.
(makeDesktopItem {
name = "Paseo";
desktopName = "Paseo";
genericName = "AI Coding Agents";
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = [ "Development" ];
startupWMClass = "Paseo";
noDisplay = true;
})
];
@@ -154,6 +255,6 @@ buildNpmPackage rec {
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux;
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
-1
View File
@@ -230,7 +230,6 @@ in
'';
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment (
+1 -1
View File
@@ -1 +1 @@
sha256-Ys/R5+0O3SDYeCSd7hatTuCwV9S0kIO8vhmPKGZsvn0=
sha256-WOVtBB7AQke7RM9FDJ1mUDU0sLMf03QOrvajrXSFwPs=
+17 -1
View File
@@ -37,6 +37,21 @@ buildNpmPackage rec {
&& !(lib.hasPrefix "/packages/website/public" relPath)
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
# Documentation, CI definitions and agent/editor configuration. None of
# these reach the build. Excluding them here also matters for the desktop
# derivation, which inherits this package's npmDeps: leaving them in makes
# a docs-only commit produce a new npm-deps .drv, and so a new desktop
# .drv, and so a full rebuild for a byte-identical result.
&& !(lib.hasPrefix "/docs" relPath)
&& !(lib.hasPrefix "/.github" relPath)
&& !(lib.hasPrefix "/.agents" relPath)
&& !(lib.hasPrefix "/.claude" relPath)
&& !(lib.hasPrefix "/.codex" relPath)
&& !(lib.hasPrefix "/docker" relPath)
# Top-level prose only (README, CHANGELOG, AGENTS...). Deeper markdown is
# not necessarily documentation: skills/*/SKILL.md is a runtime file the
# daemon's trace script copies into the output.
&& builtins.match "/[^/]+\\.md" relPath == null
# Exclude test fixtures and debug files
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
@@ -114,9 +129,10 @@ buildNpmPackage rec {
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
# Keep Paseo's runtime mode separate from NODE_ENV, which belongs to spawned agents.
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
--set NODE_ENV production
--set PASEO_NODE_ENV production
# Create wrapper for the CLI
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
+382 -252
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -7728,6 +7728,12 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -9509,6 +9515,280 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@parcel/watcher": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
"integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.6.0",
"@parcel/watcher-darwin-arm64": "2.6.0",
"@parcel/watcher-darwin-x64": "2.6.0",
"@parcel/watcher-freebsd-x64": "2.6.0",
"@parcel/watcher-linux-arm-glibc": "2.6.0",
"@parcel/watcher-linux-arm-musl": "2.6.0",
"@parcel/watcher-linux-arm64-glibc": "2.6.0",
"@parcel/watcher-linux-arm64-musl": "2.6.0",
"@parcel/watcher-linux-x64-glibc": "2.6.0",
"@parcel/watcher-linux-x64-musl": "2.6.0",
"@parcel/watcher-win32-arm64": "2.6.0",
"@parcel/watcher-win32-x64": "2.6.0"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
"integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
"integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
"integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
"integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
"integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
"integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
"integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
"integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
"integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
"integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
"integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
"integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -12678,6 +12958,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -19610,12 +19897,6 @@
"expo": "*"
}
},
"node_modules/expo-eas-client": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
"integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==",
"license": "MIT"
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -20455,12 +20736,6 @@
"node": ">=8.0"
}
},
"node_modules/expo-structured-headers": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz",
"integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==",
"license": "MIT"
},
"node_modules/expo-system-ui": {
"version": "6.0.9",
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz",
@@ -20487,36 +20762,6 @@
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
"license": "MIT"
},
"node_modules/expo-updates": {
"version": "29.0.16",
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.16.tgz",
"integrity": "sha512-E9/fxRz/Eurtc7hxeI/6ZPyHH3To9Xoccm1kXoICZTRojmuTo+dx0Xv53UHyHn4G5zGMezyaKF2Qtj3AKcT93w==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
"@expo/plist": "^0.4.8",
"@expo/spawn-async": "^1.7.2",
"arg": "4.1.0",
"chalk": "^4.1.2",
"debug": "^4.3.4",
"expo-eas-client": "~1.0.8",
"expo-manifests": "~1.0.10",
"expo-structured-headers": "~5.0.0",
"expo-updates-interface": "~2.0.0",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"ignore": "^5.3.1",
"resolve-from": "^5.0.0"
},
"bin": {
"expo-updates": "bin/cli.js"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-updates-interface": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
@@ -20526,155 +20771,6 @@
"expo": "*"
}
},
"node_modules/expo-updates/node_modules/@expo/code-signing-certificates": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
"integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==",
"license": "MIT",
"dependencies": {
"node-forge": "^1.3.3"
}
},
"node_modules/expo-updates/node_modules/@expo/plist": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz",
"integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
"base64-js": "^1.2.3",
"xmlbuilder": "^15.1.1"
}
},
"node_modules/expo-updates/node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/expo-updates/node_modules/arg": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz",
"integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==",
"license": "MIT"
},
"node_modules/expo-updates/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/expo-updates/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/expo-updates/node_modules/getenv": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
"integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/expo-updates/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo-updates/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/expo-updates/node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/expo-updates/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo-updates/node_modules/node-forge": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/expo-updates/node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo-updates/node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/expo/node_modules/@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
@@ -23182,7 +23278,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -23236,7 +23331,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -28333,6 +28427,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-throttle": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-8.1.0.tgz",
"integrity": "sha512-c1wmXavsHZIC4g1OLhOsafK6jZSAeMo0Ap3yivj59PUcCkpacy5YgWdgIp/dB4vp1JZrfBSsPCR0YuADB+ENLQ==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -33341,6 +33447,12 @@
"domino": "^2.1.6"
}
},
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"license": "MIT"
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
@@ -35211,7 +35323,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -35268,7 +35380,6 @@
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"htmlparser2": "^12.0.0",
"i18next": "^26.3.0",
@@ -35296,6 +35407,8 @@
"react-native-webview": "^13.16.0",
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"use-sync-external-store": "^1.6.0",
"zod": "^4.4.3",
"zustand": "^5.0.9"
@@ -35307,6 +35420,7 @@
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
@@ -36145,6 +36259,19 @@
"dev": true,
"license": "MIT"
},
"packages/app/node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"packages/app/node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
@@ -36236,18 +36363,19 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/server": "0.2.0-beta.2",
"@getpaseo/client": "0.2.5",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/server": "0.2.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
"tree-kill": "^1.2.2",
"ws": "^8.14.2",
"yaml": "^2.8.4"
"yaml": "^2.8.4",
"zod": "^4.4.3"
},
"bin": {
"paseo": "bin/paseo"
@@ -36487,10 +36615,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/relay": "0.2.5",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36501,7 +36629,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36744,7 +36872,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37640,7 +37768,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37872,7 +38000,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37885,7 +38013,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38103,18 +38231,19 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/highlight": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"@getpaseo/client": "0.2.5",
"@getpaseo/highlight": "0.2.5",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/relay": "0.2.5",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@parcel/watcher": "^2.6.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -38128,6 +38257,7 @@
"openai": "^6.44.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
"p-throttle": "^8.1.0",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
@@ -38155,22 +38285,22 @@
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.214.tgz",
"integrity": "sha512-wt5ImwhU+p259Zt4K/Q9v5xVi6ruxYO5+KFICyJxnjs/QFEClAeSRqhcXx1J8jgGfatPW1faw09hkm66680UjA==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.214",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.214"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -38179,9 +38309,9 @@
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.214.tgz",
"integrity": "sha512-vAAOeVtlXs3p7MpFVfvfu9ja32eaKtB+MDZnPxCbdzxJk5nofCHlNsHa1UJwXHOsP9lOnFYNIccYztsZ4DNaJA==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
"cpu": [
"arm64"
],
@@ -38192,9 +38322,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.214.tgz",
"integrity": "sha512-NTbV8U2yucxCWqEiDC7L0MUehNmd8x3Op8OGzLZRhMF3xmQBy2DxpXiNZgSwwKWNDC3HdgKmJM5ZBbT7XrF/0g==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
"cpu": [
"x64"
],
@@ -38205,9 +38335,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.214.tgz",
"integrity": "sha512-KBCf+BlusG0ZcgvpjjHwv1kh+6WiR8vJbPjpR2udwkrtcQgKLN+24l+FeaQEzO2TL+ExCmM1KC4tNPvnPpy+tw==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
"cpu": [
"arm64"
],
@@ -38218,9 +38348,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.214.tgz",
"integrity": "sha512-i06wQRsmevE7spY1ryYfs+NP+xdZ1FwAyTjDaF0k/xG+cgtzZYghTFUemdYF3GVwgWgpcava9GiCFDT3DoHI6w==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
"cpu": [
"arm64"
],
@@ -38231,9 +38361,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.214.tgz",
"integrity": "sha512-vqadSceJkBKHaTUszxI35uTuECGWtsHWK/mOwIJ4b9DUtYYySz6EJEk4gHg4ccutisJ/oRVCpFXHIKFb+osrKw==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
"cpu": [
"x64"
],
@@ -38244,9 +38374,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.214.tgz",
"integrity": "sha512-948RstHDhs0E69h+2dKYWIAL1kcwEme4zvtgNUwP8XpKXzWOhrTKmd6MAokHn25zwfuSx5d+HE0XHfFH6R4hLg==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
"cpu": [
"x64"
],
@@ -38257,9 +38387,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.214.tgz",
"integrity": "sha512-CEKlPFCPv+ee79utQMEDSsgeo2f27ulhNHpjrKIt1jXz5G04J7lAWN/QwvPDv9XaLBkWpyfqZWiPXlRcwuaO/w==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
"cpu": [
"arm64"
],
@@ -38270,9 +38400,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.214.tgz",
"integrity": "sha512-cJMJfFoR9IWBZWnTtt9PnEm89hGOsZjoDNjOCEOF3+x+g/ANIXAjMJTcaQYv2HKh6MylWZ0AkxBASI+twkukaQ==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
"cpu": [
"x64"
],
@@ -38648,7 +38778,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.0-beta.2",
"version": "0.2.5",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -39,6 +39,7 @@
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
@@ -49,9 +50,9 @@
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
"build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
-9
View File
@@ -15,7 +15,6 @@ const buildProfile = isFdroidBuild
cameraPlugins: [],
fdroidPlugins: [withFdroidAutolinking],
notificationPlugins: [],
updates: { enabled: false },
}
: {
androidPermissions: [
@@ -44,7 +43,6 @@ const buildProfile = isFdroidBuild
},
],
],
updates: {},
};
function getNativeBuildVersionCode(version) {
@@ -125,13 +123,6 @@ export default {
scheme: "paseo",
userInterfaceStyle: "automatic",
newArchEnabled: true,
runtimeVersion: {
policy: "appVersion",
},
updates: {
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
...buildProfile.updates,
},
ios: {
supportsTablet: true,
infoPlist: {
@@ -1,34 +0,0 @@
// "00-" prefix is intentional: this file must sort before every other spec.
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
import { test } from "./fixtures";
import { connectSeedClient } from "./helpers/seed-client";
import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab";
test.describe("Sessions screen empty state", () => {
test.beforeAll(async () => {
const client = await connectSeedClient();
try {
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
if (history.entries.length > 0) {
throw new Error(
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
`or the daemon has stale history from a previous run.`,
);
}
} finally {
await client.close().catch(() => undefined);
}
});
test("shows empty placeholder when there is no session history", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
await workspace.navigateTo();
await openSessions(page);
await expectSessionsEmptyState(page);
});
});
@@ -1,29 +0,0 @@
import { test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
const ACP_PROVIDER = {
id: "hermes",
name: "Hermes",
};
test.describe("ACP provider catalog", () => {
test("adds a catalog provider from settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
// Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
});
});
@@ -1,28 +0,0 @@
import { test } from "./fixtures";
import {
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
openAgentTimeline,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
} from "./helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await scrollTimelineUntilOlderHistoryIsReachable(page);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
});
@@ -0,0 +1,70 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { submitMessage } from "../support/helpers/composer";
import { choosePermissionAction, expectPermissionActions } from "../support/helpers/permissions";
import {
cleanupRewindFlow,
launchAgent,
type AgentHandle,
type RewindFlowProvider,
} from "../support/helpers/rewind-flow";
const QUESTION = "Which option should I choose?";
const OPTIONS = ["Alpha", "Beta", "Gamma"];
async function askAgentToOfferChoices(handle: AgentHandle): Promise<void> {
await submitMessage(
handle.page,
`Use your built-in question tool now. Ask exactly "${QUESTION}" with exactly three choices named Alpha, Beta, and Gamma. Do not answer the question yourself. After I choose, reply exactly SELECTED_<CHOICE>, replacing <CHOICE> with my selection.`,
);
}
async function expectAgentToResumeWithSelection(handle: AgentHandle, selection: string) {
const finish = await handle.client.waitForFinish(handle.agentId, 240_000);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeFalsy();
await expect(
handle.page
.getByTestId("assistant-message")
.filter({ hasText: `SELECTED_${selection}` })
.last(),
).toBeVisible({ timeout: 30_000 });
}
async function capturePermissionChoices(page: Page, outputPath: string) {
await page.screenshot({ path: outputPath });
await expect(page.getByText(QUESTION, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Beta" })).toBeVisible();
}
test.use({ e2eForkProviders: ["kimi"] });
test.describe("real ACP permission choices", () => {
test.setTimeout(600_000);
test("Kimi renders its question choices and resumes with the selected option", async ({
page,
}, testInfo) => {
const provider = "kimi" satisfies RewindFlowProvider;
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-acp-question-kimi-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider, cwd, mode: "full-access" });
await askAgentToOfferChoices(handle);
await expectPermissionActions(page, OPTIONS);
const screenshotPath = testInfo.outputPath("kimi-permission-choices.png");
await capturePermissionChoices(page, screenshotPath);
await testInfo.attach("Kimi permission choices", {
path: screenshotPath,
contentType: "image/png",
});
await choosePermissionAction(page, "Beta");
await expectAgentToResumeWithSelection(handle, "BETA");
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -0,0 +1,43 @@
import { test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { getServerId } from "../support/helpers/server-id";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
const ACP_PROVIDER = {
id: "hermes",
name: "Hermes",
};
interface ProviderCatalogDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
}
test.describe("ACP provider catalog", () => {
test("adds a catalog provider from settings", async ({ page }) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "provider-catalog-e2e",
});
try {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
} finally {
await client.patchDaemonConfig({ removeProviders: [ACP_PROVIDER.id] }).catch(() => undefined);
await client.close().catch(() => undefined);
}
});
});
@@ -0,0 +1,49 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
}
test("adds a changed file to the focused chat without replacing its composer draft", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-changes").click();
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
await expect(agentComposer).toBeFocused();
} finally {
await workspace.cleanup();
}
});
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { test, expect } from "../support/fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -13,36 +13,24 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
addConnectedHostAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { buildSeededHost } from "./helpers/daemon-registry";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
} from "../support/helpers/hosts";
import {
type IsolatedHostDaemon,
startIsolatedHostDaemon,
} from "../support/helpers/isolated-host-daemon";
import { expectOpenedProject } from "../support/helpers/project-picker-ui";
import { connectSeedClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
const registryEntry = buildSeededHost({
serverId: host.serverId,
label: SECONDARY_HOST_LABEL,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ key, entry }) => {
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
);
await page.reload();
}
async function expectProjectDirectory(pathname: string): Promise<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
@@ -149,7 +137,11 @@ test.describe("Add Project command-center flow", () => {
test("keyboard selection chooses the second host", async ({ page }) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -167,7 +159,11 @@ test.describe("Add Project command-center flow", () => {
page,
}) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -211,7 +207,11 @@ test.describe("Add Project command-center flow", () => {
try {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -1,7 +1,7 @@
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "./fixtures";
import { expect, test } from "../support/fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -10,12 +10,16 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { createTempGithubRepo, hasGithubAuth, type GhRepoFixture } from "./helpers/github-fixtures";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
createTempGithubRepo,
hasGithubAuth,
type GhRepoFixture,
} from "../support/helpers/github-fixtures";
import { expectOpenedProject } from "../support/helpers/project-picker-ui";
import { connectSeedClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
test.describe("Add Project GitHub flow", () => {
test.describe.configure({ timeout: 300_000 });
@@ -0,0 +1,893 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import {
attachImageFromMenu,
expectAttachmentPill,
expectComposerVisible,
submitMessage,
} from "../support/helpers/composer";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
interface FrameRect {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
}
interface ElementFrame {
mounted: boolean;
visible: boolean;
painted: boolean;
rect: FrameRect | null;
opacity: number | null;
backgroundColor: string | null;
}
interface ContentChildFrame extends ElementFrame {
key: string;
text: string;
}
interface TurnFrame {
at: number;
userRow: ElementFrame;
attachment: ElementFrame;
footerRow: ElementFrame;
spinner: ElementFrame;
agentTab: ElementFrame;
tabProgress: ElementFrame;
interruptControl: ElementFrame;
primaryActionCount: number;
composer: ElementFrame & { value: string | null };
contentChildren: ContentChildFrame[];
scroll: ElementFrame & {
scrollTop: number | null;
clientHeight: number | null;
scrollHeight: number | null;
};
}
interface ActivityCheckpoint {
row: boolean;
stop: boolean;
footer: boolean;
tabProgress: boolean;
elapsed: boolean;
}
interface ActivityOracleState {
phase: "waiting" | "observing" | "complete";
checkpoints: ActivityCheckpoint[];
observer: MutationObserver;
}
type RunningReleaseOrder = "turn-before-response" | "response-before-turn" | "snapshot-before-turn";
const FIRST_PROMPT_IMAGE = {
name: "first-prompt.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
};
declare global {
interface Window {
__consecutiveTurnFrames?: { active: boolean; frames: TurnFrame[] };
__activityContinuityOracle?: ActivityOracleState;
}
}
const test = baseTest.extend<{ streamingAgent: MockAgentWorkspace }>({
streamingAgent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "consecutive-ten-second-turns-",
title: "Consecutive ten-second turns",
model: "ten-second-stream",
initialPrompt: "emit 100 coalesced agent stream updates",
});
await agent.client.waitForFinish(agent.agentId, 15_000);
await provide(agent);
await agent.cleanup();
},
});
async function recordTurnFrames(page: Page, prompt: string): Promise<void> {
await page.evaluate((promptText) => {
const state = { active: true, frames: [] as TurnFrame[] };
const emptyElement = (): ElementFrame => ({
mounted: false,
visible: false,
painted: false,
rect: null,
opacity: null,
backgroundColor: null,
});
const rectOf = (element: Element): FrameRect => {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
};
};
const intersects = (first: FrameRect, second: FrameRect) =>
first.bottom > second.top &&
first.top < second.bottom &&
first.right > second.left &&
first.left < second.right;
const windowRect: FrameRect = {
top: 0,
right: innerWidth,
bottom: innerHeight,
left: 0,
width: innerWidth,
height: innerHeight,
};
const hasColor = (color: string) =>
color !== "" && color !== "none" && color !== "transparent" && color !== "rgba(0, 0, 0, 0)";
const isVisible = (element: Element, clip?: Element | null) => {
const rect = rectOf(element);
return (
element.isConnected &&
element.checkVisibility() &&
rect.width > 0 &&
rect.height > 0 &&
intersects(rect, windowRect) &&
(!clip || intersects(rect, rectOf(clip)))
);
};
const isPainted = (element: Element, clip?: Element | null) => {
if (!isVisible(element, clip)) return false;
return [element, ...Array.from(element.querySelectorAll("*"))].some((candidate) => {
if (!isVisible(candidate, clip)) return false;
const style = getComputedStyle(candidate);
if (Number(style.opacity) <= 0) return false;
if (hasColor(style.backgroundColor)) return true;
const hasPaintedBorder = ["Top", "Right", "Bottom", "Left"].some(
(side) =>
Number.parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 &&
hasColor(style.getPropertyValue(`border-${side.toLowerCase()}-color`)),
);
if (hasPaintedBorder) return true;
if (candidate.textContent?.trim() && hasColor(style.color)) return true;
if (candidate instanceof SVGElement) {
const fill = style.fill || candidate.getAttribute("fill") || "";
const stroke = style.stroke || candidate.getAttribute("stroke") || "";
return hasColor(fill) || hasColor(stroke);
}
return candidate instanceof HTMLImageElement || candidate instanceof HTMLCanvasElement;
});
};
const snapshot = (
element: Element | null | undefined,
clip?: Element | null,
painted?: boolean,
): ElementFrame => {
if (!element?.isConnected) return emptyElement();
const style = getComputedStyle(element);
return {
mounted: true,
visible: isVisible(element, clip),
painted: painted ?? isPainted(element, clip),
rect: rectOf(element),
opacity: Number(style.opacity),
backgroundColor: style.backgroundColor,
};
};
const unionRect = (elements: Element[]): FrameRect | null => {
if (elements.length === 0) return null;
const rects = elements.map(rectOf);
const top = Math.min(...rects.map((rect) => rect.top));
const right = Math.max(...rects.map((rect) => rect.right));
const bottom = Math.max(...rects.map((rect) => rect.bottom));
const left = Math.min(...rects.map((rect) => rect.left));
return { top, right, bottom, left, width: right - left, height: bottom - top };
};
const spinnerSnapshot = (
footer: Element | null | undefined,
clip: Element | null,
): ElementFrame => {
const dots = Array.from(footer?.querySelectorAll("*") ?? []).filter((candidate) =>
hasColor(getComputedStyle(candidate).backgroundColor),
);
if (dots.length === 0) return emptyElement();
const opacities = dots.map((dot) => Number(getComputedStyle(dot).opacity));
return {
mounted: true,
visible: dots.some((dot) => isVisible(dot, clip)),
painted: dots.some((dot, index) => opacities[index] > 0 && isVisible(dot, clip)),
rect: unionRect(dots),
opacity: Math.max(...opacities),
backgroundColor: getComputedStyle(dots[0]).backgroundColor,
};
};
const findImageAttachment = (row: Element | undefined) =>
row?.querySelector('[role="button"][aria-label="Open image attachment"]');
const findAgentTabState = () => {
const agentTab = Array.from(
document.querySelectorAll('[data-testid^="workspace-tab-agent_"]'),
).find((candidate) => isVisible(candidate));
return {
agentTab,
tabProgress: agentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]'),
};
};
const countPrimaryActions = (composerRoot: Element | null | undefined) =>
Array.from(composerRoot?.querySelectorAll('[role="button"][aria-label]') ?? []).filter(
(candidate) =>
/stop agent|canceling agent|interrupt agent|send message|send and interrupt|queue message/i.test(
candidate.getAttribute("aria-label") ?? "",
) && isVisible(candidate),
).length;
const sample = () => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const promptRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(promptText));
const attachment = findImageAttachment(promptRow);
const footer = Array.from(
viewport?.querySelectorAll('[data-testid="turn-working-indicator"]') ?? [],
)[0];
const spinner = spinnerSnapshot(footer, viewport ?? null);
const footerRow = footer?.parentElement;
const composer = Array.from(
document.querySelectorAll('[aria-label="Message agent..."]'),
).find((candidate) => isVisible(candidate));
const composerRoot = composer?.closest('[data-testid="message-input-root"]');
const interrupt = Array.from(
composerRoot?.querySelectorAll('[role="button"][aria-label]') ?? [],
).find((candidate) =>
/stop agent|canceling agent/i.test(candidate.getAttribute("aria-label") ?? ""),
);
const primaryActionCount = countPrimaryActions(composerRoot);
const { agentTab, tabProgress } = findAgentTabState();
const scrollFrame = snapshot(viewport);
const contentChildren = Array.from(viewport?.firstElementChild?.children ?? []).map(
(child, index): ContentChildFrame =>
Object.assign(snapshot(child, viewport), {
key:
(child.querySelector('[data-testid="turn-working-indicator"]')
? "turn-footer"
: (child.getAttribute("data-history-row-id") ??
child.getAttribute("data-testid"))) ?? `child-${index}`,
text: child.textContent?.trim().slice(0, 120) ?? "",
}),
);
state.frames.push({
at: performance.now(),
userRow: snapshot(promptRow, viewport),
attachment: snapshot(attachment, viewport),
footerRow: snapshot(footerRow, viewport, spinner.painted),
spinner,
agentTab: snapshot(agentTab),
tabProgress: snapshot(tabProgress),
interruptControl: snapshot(interrupt),
primaryActionCount,
composer: {
...snapshot(composerRoot ?? composer),
value:
composer instanceof HTMLInputElement || composer instanceof HTMLTextAreaElement
? composer.value
: null,
},
contentChildren,
scroll: {
...scrollFrame,
scrollTop: viewport instanceof HTMLElement ? viewport.scrollTop : null,
clientHeight: viewport instanceof HTMLElement ? viewport.clientHeight : null,
scrollHeight: viewport instanceof HTMLElement ? viewport.scrollHeight : null,
},
});
if (state.active) scheduleAfterNextPaint();
};
const scheduleAfterNextPaint = () => {
requestAnimationFrame(() => setTimeout(sample, 0));
};
window.__consecutiveTurnFrames = state;
scheduleAfterNextPaint();
}, prompt);
}
async function waitForRecordedFrames(
page: Page,
predicate: "user-visible" | "turn-running",
count: number,
): Promise<void> {
await expect
.poll(() =>
page.evaluate(
(input) => {
const frames = window.__consecutiveTurnFrames?.frames ?? [];
return frames.filter((frame) => {
if (input.predicate === "user-visible") return frame.userRow.visible;
return (
frame.interruptControl.painted &&
frame.footerRow.painted &&
frame.spinner.painted &&
frame.tabProgress.painted
);
}).length;
},
{ predicate },
),
)
.toBeGreaterThanOrEqual(count);
}
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
await expect
.poll(() => page.evaluate(() => window.__consecutiveTurnFrames?.frames.at(-1)?.at ?? 0))
.toBeGreaterThanOrEqual(until);
}
async function installActivityContinuityOracle(page: Page, prompt: string): Promise<void> {
await page.evaluate((promptText) => {
const isVisible = (element: Element | null) => Boolean(element?.checkVisibility());
const snapshot = (): ActivityCheckpoint => {
const visibleAgentTab = Array.from(
document.querySelectorAll('[data-testid^="workspace-tab-agent_"]'),
).find((candidate) => isVisible(candidate));
return {
row: Array.from(document.querySelectorAll('[data-testid="user-message"]')).some(
(candidate) => candidate.textContent?.includes(promptText),
),
stop: isVisible(document.querySelector('[role="button"][aria-label="Stop agent"]')),
footer: isVisible(document.querySelector('[data-testid="turn-working-indicator"]')),
tabProgress: isVisible(
visibleAgentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]') ??
null,
),
elapsed: isVisible(document.querySelector('[data-testid="turn-working-elapsed"]')),
};
};
const state = {
phase: "waiting" as ActivityOracleState["phase"],
checkpoints: [] as ActivityCheckpoint[],
observer: null as unknown as MutationObserver,
};
const observeCommit = () => {
const checkpoint = snapshot();
if (state.phase === "waiting" && checkpoint.row) {
state.phase = "observing";
}
if (state.phase !== "observing") return;
state.checkpoints.push(checkpoint);
if (checkpoint.elapsed) {
state.phase = "complete";
state.observer.disconnect();
}
};
state.observer = new MutationObserver(observeCommit);
window.__activityContinuityOracle = state;
state.observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
}, prompt);
}
async function waitForActivityOracleArmed(page: Page): Promise<void> {
await expect
.poll(() => page.evaluate(() => window.__activityContinuityOracle?.phase ?? "missing"))
.not.toBe("waiting");
}
async function readActivityContinuityOracle(page: Page): Promise<ActivityCheckpoint[]> {
return page.evaluate(() => {
const state = window.__activityContinuityOracle;
if (!state) throw new Error("Activity continuity oracle was never installed");
state.observer.disconnect();
return state.checkpoints;
});
}
function expectActivityContinuity(checkpoints: ActivityCheckpoint[]): void {
const incompleteActivityCheckpoints = checkpoints.filter(
(checkpoint) => !checkpoint.stop || !checkpoint.footer || !checkpoint.tabProgress,
);
expect(checkpoints[0]?.row, "activity oracle never armed on the optimistic row").toBe(true);
expect(
incompleteActivityCheckpoints,
`activity oracle observed row without full activity cluster:\n${JSON.stringify(incompleteActivityCheckpoints, null, 2)}\n\nall checkpoints:\n${JSON.stringify(checkpoints, null, 2)}`,
).toEqual([]);
expect(
checkpoints.slice(0, -1).filter((checkpoint) => checkpoint.elapsed),
`activity oracle observed elapsed before its authoritative disarm checkpoint:\n${JSON.stringify(checkpoints, null, 2)}`,
).toEqual([]);
expect(checkpoints.at(-1)?.elapsed, "activity oracle never observed authoritative elapsed").toBe(
true,
);
}
async function stopTurnFrameRecording(page: Page): Promise<TurnFrame[]> {
return page.evaluate(() => {
const state = window.__consecutiveTurnFrames;
if (!state) throw new Error("Turn frames were never recorded");
state.active = false;
return state.frames;
});
}
function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
const indexes = new Set<number>();
for (const center of Array.isArray(centers) ? centers : [centers]) {
for (let index = Math.max(0, center - 3); index <= center + 3; index += 1) {
if (index < frames.length) indexes.add(index);
}
}
return Array.from(indexes)
.sort((left, right) => left - right)
.map((index) => `frame ${index}: ${JSON.stringify(frames[index])}`)
.join("\n");
}
function expectRowContinuity(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const baseline = frames[first]?.userRow.rect?.top;
const disappeared = frames.findIndex(
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
);
const reappeared = frames.findIndex(
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
);
const shifted = frames.findIndex(
(frame, index) =>
index > first &&
hasPaintedLayout(frame.userRow) &&
baseline !== undefined &&
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
);
const attachmentMissing = frames.findIndex(
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
);
const violations = [
...(first < 0 ? ["submitted row never became visible"] : []),
...(disappeared >= 0
? [
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
]
: []),
...(shifted >= 0
? [
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
]
: []),
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
];
let failure = Math.max(0, first);
if (attachmentMissing >= 0) failure = attachmentMissing;
if (shifted >= 0) failure = shifted;
if (disappeared >= 0) failure = disappeared;
expect(
violations,
`transition violations:\n${formatFrames(frames, [failure, Math.max(failure, reappeared)])}`,
).toEqual([]);
}
interface FrameViolation {
frame: number;
reason: string;
}
function geometryChanged(
value: number | null | undefined,
expected: number | null | undefined,
): boolean {
if (value == null || expected == null) return true;
return Math.abs(value - expected) > 1;
}
function hasPaintedLayout(element: ElementFrame): boolean {
return [element.mounted, element.visible, element.painted].every(Boolean);
}
function violationsForChecks(
frame: number,
checks: Array<{ passes: boolean; reason: string }>,
): FrameViolation[] {
return checks.filter(({ passes }) => !passes).map(({ reason }) => ({ frame, reason }));
}
function collectElementViolations(
frame: TurnFrame,
baseline: TurnFrame | undefined,
index: number,
): FrameViolation[] {
return violationsForChecks(index, [
{
passes: hasPaintedLayout(frame.userRow),
reason: "submitted row left the painted layout",
},
{
passes: !geometryChanged(frame.userRow.rect?.top, baseline?.userRow.rect?.top),
reason: "submitted row moved vertically",
},
{
passes: [hasPaintedLayout(frame.footerRow), (frame.footerRow.rect?.height ?? 0) > 0].every(
Boolean,
),
reason: "footer row was absent from the painted layout",
},
{
passes: [
geometryChanged(frame.footerRow.rect?.top, baseline?.footerRow.rect?.top),
geometryChanged(frame.footerRow.rect?.height, baseline?.footerRow.rect?.height),
].every((hasChanged) => !hasChanged),
reason: "footer geometry moved",
},
{
passes: hasPaintedLayout(frame.spinner),
reason: "working spinner was not painted",
},
{
passes: hasPaintedLayout(frame.tabProgress),
reason: "selected tab running indicator was not painted",
},
{
passes: hasPaintedLayout(frame.interruptControl),
reason: "interrupt control was not painted",
},
{
passes: frame.primaryActionCount === 1,
reason: `expected one primary action, found ${frame.primaryActionCount}`,
},
{
passes: [hasPaintedLayout(frame.composer), frame.composer.value === ""].every(Boolean),
reason: "composer was not painted and empty",
},
{
passes: [
geometryChanged(frame.composer.rect?.top, baseline?.composer.rect?.top),
geometryChanged(frame.composer.rect?.height, baseline?.composer.rect?.height),
].every((hasChanged) => !hasChanged),
reason: "composer geometry moved",
},
]);
}
function collectScrollViolations(
frame: TurnFrame,
baseline: TurnFrame | undefined,
index: number,
): FrameViolation[] {
return violationsForChecks(index, [
{
passes: [
frame.scroll.mounted,
frame.scroll.visible,
!geometryChanged(frame.scroll.rect?.top, baseline?.scroll.rect?.top),
!geometryChanged(frame.scroll.rect?.height, baseline?.scroll.rect?.height),
!geometryChanged(frame.scroll.clientHeight, baseline?.scroll.clientHeight),
].every(Boolean),
reason: "scroll viewport geometry moved",
},
{
passes: [
geometryChanged(frame.scroll.scrollTop, baseline?.scroll.scrollTop),
geometryChanged(frame.scroll.scrollHeight, baseline?.scroll.scrollHeight),
].every((hasChanged) => !hasChanged),
reason: "scroll content metrics moved",
},
]);
}
function collectContentViolations(
frame: TurnFrame,
stableContentChildren: Map<string, ContentChildFrame>,
index: number,
): FrameViolation[] {
return Array.from(stableContentChildren).flatMap(([key, expected]) => {
const child = frame.contentChildren.find((candidate) => candidate.key === key);
return violationsForChecks(index, [
{
passes: [
Boolean(child?.mounted),
!geometryChanged(child?.rect?.top, expected.rect?.top),
!geometryChanged(child?.rect?.bottom, expected.rect?.bottom),
!geometryChanged(child?.rect?.height, expected.rect?.height),
].every(Boolean),
reason: `content row ${key} geometry moved`,
},
]);
});
}
function expectAtomicIdleToRunningTransition(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => frame.userRow.visible);
const running = frames.findIndex(
(frame, index) =>
index >= first &&
frame.interruptControl.painted &&
frame.footerRow.painted &&
frame.spinner.painted &&
frame.tabProgress.painted,
);
const transition = frames.slice(first);
const baseline = transition[0];
const violations: FrameViolation[] = [];
const stableContentChildren = new Map(
baseline?.contentChildren
.filter(({ key }) => key !== "turn-footer")
.map((child) => [child.key, child]),
);
if (first < 0) violations.push({ frame: 0, reason: "submitted row never became visible" });
if (running < 0) violations.push({ frame: Math.max(first, 0), reason: "turn never visibly ran" });
for (const [offset, frame] of transition.entries()) {
const index = first + offset;
violations.push(...collectElementViolations(frame, baseline, index));
violations.push(...collectScrollViolations(frame, baseline, index));
violations.push(...collectContentViolations(frame, stableContentChildren, index));
}
for (let offset = 1; offset < transition.length; offset += 1) {
const before = transition[offset - 1];
const after = transition[offset];
const footerEnteredLayout = [
(before.footerRow.rect?.height ?? 0) <= 0,
(after.footerRow.rect?.height ?? 0) > 0,
].every(Boolean);
if (footerEnteredLayout) {
violations.push({
frame: first + offset,
reason: "footer height changed from zero to non-zero",
});
}
}
const failure = violations[0]?.frame ?? Math.max(first, 0);
expect(
violations,
`transition violations:\n${violations.map(({ frame, reason }) => `frame ${frame}: ${reason}`).join("\n")}\n\nframe record:\n${formatFrames(frames, [failure, running])}`,
).toEqual([]);
}
function expectSubmittedMessageHeightStable(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => frame.userRow.visible);
const baseline = frames[first]?.userRow.rect?.height;
const changes = frames
.slice(first)
.flatMap((frame, offset) =>
geometryChanged(frame.userRow.rect?.height, baseline)
? [`frame ${first + offset}: submitted row height changed`]
: [],
);
expect(changes, `submitted message geometry changed:\n${changes.join("\n")}`).toEqual([]);
}
function expectAtomicFirstPromptTransition(frames: TurnFrame[]): void {
expectRowContinuity(frames);
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const transition = frames.slice(first);
const violations: FrameViolation[] = [];
for (const [offset, frame] of transition.entries()) {
const index = first + offset;
violations.push(
...violationsForChecks(index, [
{
passes: hasPaintedLayout(frame.footerRow),
reason: "footer was not painted with the first prompt",
},
{
passes: hasPaintedLayout(frame.spinner),
reason: "working spinner was not painted with the first prompt",
},
{
passes: hasPaintedLayout(frame.composer) && frame.composer.value === "",
reason: "composer was not painted and empty with the first prompt",
},
{
passes: !frame.agentTab.mounted || hasPaintedLayout(frame.tabProgress),
reason: "created agent tab appeared without running state",
},
{
passes: !frame.agentTab.mounted || hasPaintedLayout(frame.interruptControl),
reason: "created agent appeared without its interrupt control",
},
]),
);
}
const failure = violations[0]?.frame ?? Math.max(first, 0);
expect(
violations,
`transition violations:\n${violations.map(({ frame, reason }) => `frame ${frame}: ${reason}`).join("\n")}\n\nframe record:\n${formatFrames(frames, failure)}`,
).toEqual([]);
}
async function recordDelayedRunningTransition(
page: Page,
agent: MockAgentWorkspace,
releaseOrder: RunningReleaseOrder,
): Promise<{ frames: TurnFrame[]; checkpoints: ActivityCheckpoint[] }> {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectAgentIdle(page);
await expect(page.getByRole("button", { name: "Copy turn" })).toHaveCount(1);
const prompt = "Second prompt keeps streaming.";
gate.holdNextAgentStreamEvent("turn_started");
gate.holdNextAgentUpdate(agent.agentId, "running");
gate.holdNextServerMessage("send_agent_message_response");
gate.setAgentStreamItemSuppressed("assistant_message", true);
await recordTurnFrames(page, prompt);
await installActivityContinuityOracle(page, prompt);
await submitMessage(page, prompt);
await Promise.all([
gate.waitForHeldAgentStreamEvent("turn_started"),
gate.waitForHeldAgentUpdate(agent.agentId, "running"),
gate.waitForHeldServerMessage("send_agent_message_response"),
]);
let turnReleased = false;
let snapshotReleased = false;
let responseReleased = false;
try {
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue("");
await gate.waitForAgentStreamItem("user_message");
await waitForActivityOracleArmed(page);
const elapsedBeforeAuthoritative = await page.getByTestId("turn-working-elapsed").count();
if (releaseOrder === "turn-before-response") {
gate.releaseHeldAgentStreamEvent("turn_started");
turnReleased = true;
await expect(page.getByTestId("turn-working-elapsed")).toBeVisible();
gate.releaseHeldServerMessage("send_agent_message_response");
responseReleased = true;
gate.releaseHeldAgentUpdate(agent.agentId, "running");
snapshotReleased = true;
} else if (releaseOrder === "response-before-turn") {
gate.releaseHeldServerMessage("send_agent_message_response");
responseReleased = true;
await recordPaintsFor(page, 80);
gate.releaseHeldAgentStreamEvent("turn_started");
turnReleased = true;
await expect(page.getByTestId("turn-working-elapsed")).toBeVisible();
gate.releaseHeldAgentUpdate(agent.agentId, "running");
snapshotReleased = true;
} else {
gate.releaseHeldServerMessage("send_agent_message_response");
responseReleased = true;
await recordPaintsFor(page, 80);
gate.releaseHeldAgentUpdate(agent.agentId, "running");
snapshotReleased = true;
await expect(page.getByTestId("turn-working-elapsed")).toBeVisible();
gate.releaseHeldAgentStreamEvent("turn_started");
turnReleased = true;
}
await waitForRecordedFrames(page, "turn-running", 3);
const frames = await stopTurnFrameRecording(page);
const checkpoints = await readActivityContinuityOracle(page);
expectActivityContinuity(checkpoints);
expect(elapsedBeforeAuthoritative).toBe(0);
gate.setAgentStreamItemSuppressed("assistant_message", false);
return { frames, checkpoints };
} finally {
if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started");
if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running");
if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response");
gate.setAgentStreamItemSuppressed("assistant_message", false);
}
}
// The first prompt of a brand new agent is submitted before the agent exists, so the
// authoritative timeline arrives after the row is already on screen. That is the only
// path where hydration can move an already-visible message.
test("keeps the first prompt of a new agent in place through authoritative hydration", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "new-agent-first-prompt-" });
try {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify({
provider: "mock",
providerPreferences: {
mock: { mode: "load-test", model: "ten-second-stream" },
},
}),
);
});
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
const prompt = "Delay synthetic user message by 300ms.";
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await recordTurnFrames(page, prompt);
await submitMessage(page, prompt);
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
await expect(submittedRow).toBeVisible();
await gate.waitForServerMessage("fetch_agent_timeline_response");
await recordPaintsFor(page, 80);
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
} finally {
await workspace.cleanup();
}
});
test("keeps follow-up activity continuous when turn starts before response", async ({
page,
streamingAgent,
}) => {
const { frames } = await recordDelayedRunningTransition(
page,
streamingAgent,
"turn-before-response",
);
expectAtomicIdleToRunningTransition(frames);
});
test("keeps a submitted message height stable when its canonical echo arrives", async ({
page,
streamingAgent,
}) => {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, {
workspaceId: streamingAgent.workspaceId,
agentId: streamingAgent.agentId,
});
await expectAgentIdle(page);
const prompt = "Canonical echo must not resize this message.";
gate.holdNextAgentStreamItem("user_message");
await recordTurnFrames(page, prompt);
await submitMessage(page, prompt);
await gate.waitForHeldAgentStreamItem("user_message");
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(submittedRow).toBeVisible();
await recordPaintsFor(page, 80);
gate.releaseHeldAgentStreamItem("user_message");
await expect(submittedRow.getByTestId("rewind-menu-trigger")).toBeVisible();
await recordPaintsFor(page, 80);
expectSubmittedMessageHeightStable(await stopTurnFrameRecording(page));
});
test("keeps follow-up activity continuous when response arrives before running state", async ({
page,
streamingAgent,
}) => {
const { frames } = await recordDelayedRunningTransition(
page,
streamingAgent,
"response-before-turn",
);
expectAtomicIdleToRunningTransition(frames);
});
test("keeps follow-up activity continuous when snapshot opens before turn event", async ({
page,
streamingAgent,
}) => {
const { frames } = await recordDelayedRunningTransition(
page,
streamingAgent,
"snapshot-before-turn",
);
expectAtomicIdleToRunningTransition(frames);
});
@@ -0,0 +1,71 @@
import { expect, test } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
captureHeartbeatEvidence,
deleteHeartbeat,
expectHeartbeatDetails,
expectHeartbeatVisible,
expectWorkspaceDone,
expectWorkspaceRunning,
openHeartbeat,
openHeartbeatAndSubagentTracks,
openHeartbeatTrack,
removeSeededSubagent,
returnToAgentHeartbeat,
seedAgentWithHeartbeat,
} from "../support/helpers/heartbeats";
test("an agent heartbeat stays visible and keeps its workspace running", async ({
page,
}, testInfo) => {
test.setTimeout(150_000);
const scenario = await seedAgentWithHeartbeat();
try {
await openAgentRoute(page, {
workspaceId: scenario.workspace.workspaceId,
agentId: scenario.parent.id,
});
await expectWorkspaceDone(page);
const heartbeat = await scenario.createHeartbeat();
await expectWorkspaceRunning(page);
await openHeartbeatAndSubagentTracks(page, heartbeat, scenario.child.id);
await expectHeartbeatVisible(page, heartbeat);
await captureHeartbeatEvidence(page, testInfo, "heartbeat-stacked-tracks");
await openHeartbeat(page, heartbeat);
await expectHeartbeatDetails(page, heartbeat);
await captureHeartbeatEvidence(page, testInfo, "heartbeat-schedule-details");
await removeSeededSubagent(scenario);
await returnToAgentHeartbeat(page);
await expectWorkspaceRunning(page);
await openHeartbeatTrack(page, heartbeat);
await deleteHeartbeat(page, heartbeat);
await expect
.poll(() => scenario.readHeartbeatExists(heartbeat.id), { timeout: 30_000 })
.toBe(false);
await expect(scenario.readAgentStatuses()).resolves.toMatchObject({
[scenario.parent.id]: "idle",
});
await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done");
await expectWorkspaceDone(page);
await captureHeartbeatEvidence(page, testInfo, "heartbeat-removed-workspace-done");
const expiringHeartbeat = await scenario.createHeartbeat({ name: "Temporary watch" });
await expectWorkspaceRunning(page);
await openHeartbeatTrack(page, expiringHeartbeat);
await expectHeartbeatVisible(page, expiringHeartbeat);
await scenario.setHeartbeatExpiry(
expiringHeartbeat.id,
new Date(Date.now() + 20_000).toISOString(),
);
await expect(page.getByRole("button", { name: "1 heartbeat" })).toHaveCount(0, {
timeout: 30_000,
});
await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done");
await expectWorkspaceDone(page);
} finally {
await scenario.cleanup().catch(() => undefined);
}
});
@@ -0,0 +1,133 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import {
cancelAgent,
expectComposerDraft,
expectComposerVisible,
submitMessage,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function completeSubmittedTurn(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
prompt: string,
): Promise<Locator> {
await submitMessage(page, prompt);
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
}
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
await userMessage.getByText(prompt, { exact: true }).hover();
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
await page.getByRole("button", { name: "Rewind conversation", exact: true }).click();
}
async function expectTurnCompletesNormally(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
): Promise<void> {
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expectAgentIdle(page);
}
test.describe("Agent message rewind", () => {
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Restore this submitted prompt after rewind.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-roundtrip-",
title: "Message rewind roundtrip",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const userMessage = await completeSubmittedTurn(page, agent, prompt);
await rewindConversation(page, userMessage, prompt);
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await expectComposerDraft(page, prompt);
} finally {
await agent.cleanup();
}
});
test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Delay synthetic user message by 2000ms.";
const rewindError = "Cannot rewind before the provider acknowledges the submitted prompt";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-pre-echo-",
title: "Message rewind pre-echo",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessage(page, prompt);
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
await expectAgentReadyToInterrupt(page);
await rewindConversation(page, userMessage, prompt);
await expect(page.getByText(rewindError, { exact: true })).toBeVisible();
await expectAgentReadyToInterrupt(page);
await expectTurnCompletesNormally(page, agent);
} finally {
await agent.cleanup();
}
});
test("does not offer rewind for daemon-handled prompts", async ({ page }) => {
const commandPrompt = "/mock handled-command";
const providerPrompt = "Keep this provider-bound prompt rewindable.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-daemon-command-",
title: "Message rewind daemon command",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expectAgentIdle(page);
await submitMessage(page, commandPrompt);
const commandMessage = page.getByTestId("user-message").filter({ hasText: commandPrompt });
await expect(commandMessage).toBeVisible();
await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible();
await commandMessage.getByText(commandPrompt, { exact: true }).hover();
await expect(
commandMessage.getByRole("button", { name: "Rewind to this message", exact: true }),
).toHaveCount(0);
await submitMessage(page, providerPrompt);
const providerMessage = page.getByTestId("user-message").filter({ hasText: providerPrompt });
await expect(providerMessage).toBeVisible();
await expectAgentReadyToInterrupt(page);
await providerMessage.getByText(providerPrompt, { exact: true }).hover();
await expect(
providerMessage.getByRole("button", { name: "Rewind to this message", exact: true }),
).toBeVisible();
await cancelAgent(page);
await expectAgentIdle(page);
} finally {
await agent.cleanup();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,12 @@
import { test, expect } from "./fixtures";
import { test, expect } from "../support/fixtures";
import {
awaitAssistantMessage,
expectAgentIdle,
expectInlineWorkingIndicator,
expectRunningAgentChrome,
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
} from "../support/helpers/agent-stream";
import {
expectScrollStaysFixed,
clickToolCallBesideScrollToBottomButton,
@@ -13,16 +14,40 @@ import {
scrollAgentChatToBottom,
scrollChatAwayFromBottom,
waitForScrollableChat,
} from "./helpers/agent-bottom-anchor";
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
import { selectModel } from "./helpers/app";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
} from "../support/helpers/agent-bottom-anchor";
import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate";
import { selectModel } from "../support/helpers/app";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible, startRunningMockAgent } from "../support/helpers/composer";
import {
openAgentRoute,
seedMockAgentWorkspace,
seedRunningMockAgentWorkspace,
} from "../support/helpers/mock-agent";
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
test.describe("Agent stream UI", () => {
test("keeps running agent chrome after page refresh", async ({ page }) => {
const title = "Running agent refresh";
const agent = await seedRunningMockAgentWorkspace({
repoPrefix: "stream-running-refresh-",
title,
model: "five-minute-stream",
initialPrompt: "Stay running while the page refreshes.",
});
try {
await openAgentRoute(page, agent);
await expectRunningAgentChrome(page, title);
await page.reload();
await expectRunningAgentChrome(page, title);
} finally {
await agent.cleanup();
}
});
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
@@ -0,0 +1,85 @@
import { test as base } from "../support/fixtures";
import {
appendSettledTimelineTurns,
createNearTenMegabyteAssistantPng,
createSettledMockAgent,
createSmallAssistantPng,
emitSettledAssistantImage,
expectAssistantImageNotMounted,
expectAssistantImageRendered,
openAssistantImageTimeline,
openExistingImageAgentTabs,
remountAndRecoverAssistantImageFromHistory,
sendFollowUpAndExpectVisibleResponse,
switchAwayAndBackWithoutImageInstability,
userPagesUntilAssistantImageRenders,
} from "../support/helpers/assistant-images";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
const workspace = await seedWorkspace({ repoPrefix: "agent-tab-image-stability-" });
try {
await provide(workspace);
} finally {
await workspace.cleanup();
}
},
});
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Real file image",
fileName: "assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Image timeline");
const otherAgent = await createSettledMockAgent(workspace, "Other timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openExistingImageAgentTabs(page, { imageAgent, otherAgent });
await expectAssistantImageRendered(page, image);
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
});
test("a real assistant PNG remains reachable through pagination and remount", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Paginated real file image",
fileName: "paginated-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Paginated image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await appendSettledTimelineTurns(workspace.client, imageAgent, 40);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageNotMounted(page, image);
await userPagesUntilAssistantImageRenders(page, image);
await remountAndRecoverAssistantImageFromHistory(page, image);
});
test("a near-10 MiB real assistant PNG renders and the app remains responsive", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(180_000);
const image = await createNearTenMegabyteAssistantPng(workspace, {
alt: "Large real file image",
fileName: "large-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Large image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await sendFollowUpAndExpectVisibleResponse(page, {
prompt: "confirm responsiveness: emit 1 coalesced agent stream updates",
response: "stress-update-0",
});
});
@@ -0,0 +1,86 @@
import { randomUUID } from "node:crypto";
import { metroTest as test } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectTimelinePromptVisible,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
} from "../support/helpers/timeline-pagination";
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const workspace = await seedWorkspace({
repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
});
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Published daemon pagination regression",
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
try {
for (let index = 0; index < 40; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
);
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
} finally {
await agent.cleanup();
await daemon.close();
}
});
@@ -0,0 +1,259 @@
import { expect, test } from "../support/fixtures";
import {
expectSameOlderHistoryLoadingOperation,
expectStableHistoryStartGutter,
expectTimelineAtHistoryStart,
expectTimelinePromptCentered,
expectTimelinePromptNotMounted,
expectTimelinePromptPositionPreserved,
expectTimelinePromptVisible,
holdBootstrapTimelinePage,
holdDaemonHydration,
holdOlderHistoryPages,
makeLoadedTimelineFitViewport,
openAgentTimeline,
rememberOlderHistoryLoadingOperation,
rememberTimelineViewport,
rememberTimelinePromptPosition,
reloadAgentTimelineFromPersistedReplica,
scrollTimelineUntilOlderHistoryIsReachable,
scrollTimelineToOldestLoadedEdge,
scrollTimelineToNewestLoadedEdge,
seedLongMockAgentTimeline,
scrollThroughOlderHistoryPages,
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectStableHistoryStartGutter(page);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await expectStableHistoryStartGutter(page);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectStableHistoryStartGutter(page);
} finally {
await agent.cleanup();
}
});
test("loads one page each time the user returns to history start", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptCentered(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(2);
} finally {
await agent.cleanup();
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userNavigatesTimelineToHistoryStartWithKeyboard(page);
await history.expectRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("does not repeat an assistant block that spans older history pages", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedRealisticOlderTimeline();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectNoRepeatedEntries();
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
} finally {
await agent.cleanup();
}
});
test("keeps visible history anchored when live output grows during a prepend", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
await agent.client.sendAgentMessage(
agent.agentId,
"timeline live during held older page: emit 20 coalesced agent stream updates",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
} finally {
await agent.cleanup();
}
});
test("finishes loading an older page while live output continues", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await agent.client.sendAgentMessage(agent.agentId, "keep streaming while history settles");
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "running",
);
history.releasePage(1);
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
const running = await agent.client.fetchAgents({ scope: "active" });
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
"running",
);
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("continues one loading operation while older pages still leave history start exposed", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await makeLoadedTimelineFitViewport(page);
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await scrollTimelineToOldestLoadedEdge(page);
await history.expectRequestedPages(1);
const loading = await rememberOlderHistoryLoadingOperation(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectTimelineAtHistoryStart(page);
await expectSameOlderHistoryLoadingOperation(page, loading);
} finally {
await agent.cleanup();
}
});
test("keeps complete loaded history reachable after reload", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
const hydration = await holdDaemonHydration(page);
await reloadAgentTimelineFromPersistedReplica(page, agent);
hydration.release();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
test("preserves a live row received before replica hydration", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
const hydration = await holdBootstrapTimelinePage(page, agent);
await reloadAgentTimelineFromPersistedReplica(page, agent);
await hydration.waitForDelayedResponse();
const livePrompt = await sendLiveTurnBeforeHydration(agent);
await expectTimelinePromptVisible(page, livePrompt);
hydration.release();
await hydration.waitForDelayedCatchUp();
await expectTimelinePromptVisible(page, livePrompt);
hydration.releaseCatchUp();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, livePrompt);
} finally {
await agent.cleanup();
}
});
});
@@ -0,0 +1,65 @@
import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectTimelinePresentationUnchanged,
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
openAgentTimeline,
rememberTimelinePresentation,
scrollTimelinePromptIntoView,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectOneTailCheckWithoutCatchUpPages,
rememberTimelineRequestCounts,
restoreViewedTimelineWithHeldTail,
} from "../support/helpers/timeline-resume";
test.describe("Agent timeline resume", () => {
test("a long disconnected run lands as one latest-tail update", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
await restoreViewedTimelineWithHeldTail(page, gate);
await expectTimelinePromptVisible(page, background.newestPrompt);
await expectTimelinePromptNotMounted(page, background.firstPrompt);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
} finally {
gate.restore();
await agent.cleanup();
}
});
test("an unchanged resume leaves a scrolled-up timeline untouched", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await scrollTimelinePromptIntoView(page, agent.initialTailAnchorPrompt);
const presentation = await rememberTimelinePresentation(page, agent.initialTailAnchorPrompt);
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
await restoreViewedTimelineWithHeldTail(page, gate);
await expectTimelinePresentationUnchanged(page, presentation);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
}
});
});
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { test } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import {
archiveAgentFromDaemon,
@@ -20,7 +20,7 @@ import {
primeAdditionalPage,
resetSeededPageState,
reloadWorkspace,
} from "./helpers/archive-tab";
} from "../support/helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
@@ -0,0 +1,115 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "../support/fixtures";
import { openSessions } from "../support/helpers/archive-tab";
import {
assertChatTranscript,
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
} from "../support/helpers/rewind-flow";
import type { SeedDaemonClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
interface TimelineClient extends SeedDaemonClient {
fetchAgentTimeline(
agentId: string,
options: { direction: "tail"; projection: "projected"; limit: number },
): Promise<unknown>;
}
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
return history.entries.some((entry) => entry.agent.id === agentId);
}
test.describe("archived Codex agent recovery", () => {
test.setTimeout(600_000);
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
});
await sendMessage(handle, INITIAL_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await handle.client.archiveAgent(handle.agentId);
await expect
.poll(
async () =>
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
null,
{ timeout: 30_000 },
)
.not.toBeNull();
const timelineClient = handle.client as TimelineClient;
await expect(
timelineClient.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "projected",
limit: 100,
}),
).rejects.toThrow(/archiv/i);
await expect
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
timeout: 30_000,
})
.toBe(true);
await page.reload();
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
await expect(page.getByTestId("user-message")).toHaveCount(0);
await page.getByRole("button", { name: "Unarchive" }).click();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 60_000,
});
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await sendMessage(handle, FOLLOW_UP_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
{ role: "user", text: FOLLOW_UP_PROMPT },
{ role: "assistant", text: FOLLOW_UP_REPLY },
]);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -1,17 +1,25 @@
import { expect, test as base, type Page } from "./fixtures";
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "./helpers/agent-stream";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { expect, test as base } from "../support/fixtures";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import {
expectChatHistoryAttachment,
expectInFlightForkAvailable,
expectLiveAssistantText,
forkInFlightTurnToNewTab,
forkMostRecentAssistantTurnToNewTab,
forkMostRecentAssistantTurnToNewWorkspace,
observeForkAttachment,
} from "../support/helpers/assistant-fork";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentOptions,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { getServerId } from "./helpers/server-id";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { submitNewWorkspaceEmpty } from "./helpers/new-workspace";
} from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { submitNewWorkspaceEmpty } from "../support/helpers/new-workspace";
const test = base.extend<{
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
@@ -27,30 +35,6 @@ const test = base.extend<{
},
});
async function openAssistantForkMenu(page: Page): Promise<void> {
await expect
.poll(
async () => {
await scrollAgentChatToBottom(page);
return page.getByTestId("assistant-fork-menu-trigger").count();
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
timeout: 10_000,
});
}
async function expectChatHistoryPill(page: Page): Promise<void> {
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
await expect(pill).toBeVisible({ timeout: 30_000 });
await expect(pill).toContainText("Chat history");
}
test.describe("Assistant fork menu", () => {
test.describe.configure({ timeout: 180_000 });
@@ -71,9 +55,38 @@ test.describe("Assistant fork menu", () => {
timeout: 30_000,
});
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
await expectChatHistoryPill(page);
await forkMostRecentAssistantTurnToNewTab(page);
await expectChatHistoryAttachment(page);
});
test("forks a streaming assistant turn without interrupting it", async ({
page,
seedForkWorkspace,
}) => {
const visibleBeforeFork = "where the auto-scroll logic actually lives";
const visibleAfterFork = "the first useful step is to read the relevant files";
const sourceAgentTitle = "Assistant fork in flight";
const forkAttachment = observeForkAttachment(page);
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-in-flight-",
title: sourceAgentTitle,
model: "thirty-minute-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await submitMessage(page, "Walk me through the scroll anchor behavior.");
await expectInFlightForkAvailable(page);
await expectLiveAssistantText(page, visibleBeforeFork);
await forkInFlightTurnToNewTab(page);
await expectChatHistoryAttachment(page);
expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork);
await page.getByRole("button", { name: sourceAgentTitle }).click();
await expectLiveAssistantText(page, visibleAfterFork);
});
test("focuses a forked assistant turn in a new workspace draft tab", async ({
@@ -99,8 +112,7 @@ test.describe("Assistant fork menu", () => {
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
await expect(agentTab).toHaveAttribute("aria-selected", "true");
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
await forkMostRecentAssistantTurnToNewTab(page);
const selectedTab = page
.getByTestId("workspace-tabs-row")
@@ -110,7 +122,7 @@ test.describe("Assistant fork menu", () => {
timeout: 30_000,
});
await expect(agentTab).toHaveAttribute("aria-selected", "false");
await expectChatHistoryPill(page);
await expectChatHistoryAttachment(page);
});
test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({
@@ -129,9 +141,8 @@ test.describe("Assistant fork menu", () => {
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
await expectChatHistoryPill(page);
await forkMostRecentAssistantTurnToNewTab(page);
await expectChatHistoryAttachment(page);
await submitMessage(page, "");
@@ -171,17 +182,16 @@ test.describe("Assistant fork menu", () => {
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-workspace").click();
await forkMostRecentAssistantTurnToNewWorkspace(page);
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
await expectChatHistoryPill(page);
await expectChatHistoryAttachment(page);
await page.getByTestId("host-picker-trigger").click();
await page
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
.click();
await expectChatHistoryPill(page);
await expectChatHistoryAttachment(page);
});
test("keeps the fork attachment after the new agent receives its user message", async ({
@@ -200,9 +210,8 @@ test.describe("Assistant fork menu", () => {
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-workspace").click();
await expectChatHistoryPill(page);
await forkMostRecentAssistantTurnToNewWorkspace(page);
await expectChatHistoryAttachment(page);
await submitNewWorkspaceEmpty(page);
@@ -0,0 +1,315 @@
import type { BrowserContext } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const ASSISTANT_MARKDOWN = [
"Direct matches:",
"",
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
"",
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
"",
"5. Fifth item",
"6. Sixth item",
"7. Seventh item",
"8. Eighth item",
"",
"Nested list:",
"",
"3. Outer three",
"",
" 7. Inner seven",
" 8. Inner eight",
" 9. Inner nine",
"",
"A hard break ",
"stays hard.",
"",
"www.example.com stays plain Markdown text.",
"",
'[docs](https://docs.example.com "Reference") and <https://autolink.example.com>.',
"",
"[workspace file](file:///tmp/paseo%20notes.md#L4)",
"",
"`https://example.com/generated` stays code, not a generated link.",
"",
"```typescript",
'const answer = "yes";',
"```",
"",
"````text",
"before",
"```",
"after",
"````",
"",
"| Left | Right | Center |",
"| :-- | --: | :-: |",
"| Current \\| active | ~~obsolete~~ | ready |",
].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",
);
interface ClipboardContent {
html: string;
plainText: string;
}
async function allowRichClipboard(context: BrowserContext): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
}
async function selectAssistantMessage(page: Page): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await expect(assistantMessage).toBeVisible();
await assistantMessage.evaluate((element) => {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(element);
selection?.removeAllRanges();
selection?.addRange(range);
});
}
async function selectAssistantText(page: Page, text: string): Promise<void> {
await selectAssistantTextRange(page, text, text);
}
async function selectAssistantTextRange(
page: Page,
startText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let startNode: Node | null = null;
let startOffset = -1;
let endNode: Node | null = null;
let endOffset = -1;
let textNode = walker.nextNode();
while (textNode) {
if (!startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
if (offset >= 0) {
startNode = textNode;
startOffset = offset;
}
}
if (startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
if (offset >= 0) {
endNode = textNode;
endOffset = offset + selectedRange.endText.length;
break;
}
}
textNode = walker.nextNode();
}
if (!startNode || !endNode) {
throw new Error(
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
);
}
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
},
{ startText, endText },
);
}
async function copySelection(page: Page): Promise<void> {
await page.keyboard.press("ControlOrMeta+c");
}
async function readRichClipboard(page: Page): Promise<ClipboardContent> {
return page.evaluate(async () => {
const items = await navigator.clipboard.read();
const item = items[0];
if (!item) {
throw new Error("Expected clipboard content");
}
return {
html: await (await item.getType("text/html")).text(),
plainText: await (await item.getType("text/plain")).text(),
};
});
}
test("copying an assistant selection preserves Markdown structure and links", async ({
context,
page,
}) => {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "serif" }));
});
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-copy-",
title: "Assistant selection copy",
initialPrompt: "Render the clipboard fixture.",
featureValues: { mockAssistantResponse: ASSISTANT_MARKDOWN },
});
try {
await allowRichClipboard(context);
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await openAgentRoute(page, agent);
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
for (const [tag, text] of [
["strong", "strong prose"],
["em", "emphasized prose"],
["s", "struck prose"],
]) {
const formattedProse = assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text });
await expect(formattedProse).toHaveCSS("font-family", "serif");
await expect(formattedProse).not.toHaveAttribute("data-pmono");
}
const inlineCode = assistantMessage
.locator('[data-paseo-markdown-tag="code"]')
.filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveAttribute("data-pmono", "");
await selectAssistantMessage(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).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("A hard break<br>");
expect(clipboard.html).toContain(
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.',
);
expect(clipboard.html).toContain(
'<a href="https://docs.example.com/" title="Reference">docs</a>',
);
expect(clipboard.html).toContain(
'<a href="https://autolink.example.com/">https://autolink.example.com</a>',
);
expect(clipboard.html).toContain(
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
);
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
expect(clipboard.html).not.toContain(
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
);
expect(clipboard.html).toContain('<code class="language-typescript">');
expect(clipboard.html).toContain(
'<pre><code class="language-text">before\n```\nafter\n</code></pre>',
);
expect(clipboard.html).toContain("<table>");
expect(clipboard.html).toContain('<td style="text-align:left">Current | active</td>');
expect(clipboard.html.match(/<td\b/g)).toHaveLength(3);
expect(clipboard.html).toContain("<s>obsolete</s>");
expect(clipboard.html).toContain('<th style="text-align:left">Left</th>');
expect(clipboard.html).toContain('<th style="text-align:right">Right</th>');
expect(clipboard.html).toContain('<th style="text-align:center">Center</th>');
await 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>',
);
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>',
);
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>',
);
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>',
);
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">');
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">');
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">');
await selectAssistantText(page, "Current");
await copySelection(page);
const partialTableClipboard = await readRichClipboard(page);
expect(partialTableClipboard.plainText).toBe("Current");
expect(partialTableClipboard.html).toContain("<p>Current</p>");
expect(partialTableClipboard.html).not.toContain("&lt;table");
await selectAssistantTextRange(page, "Right", "ready");
await copySelection(page);
const columnSliceClipboard = await readRichClipboard(page);
expect(columnSliceClipboard.plainText).toContain("ready");
expect(columnSliceClipboard.html).not.toContain("<table>");
expect(columnSliceClipboard.html).toContain("ready");
expect(columnSliceClipboard.html).toContain("<s>obsolete</s>");
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,185 @@
import { existsSync } from "node:fs";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
clickSessionRow,
expectSessionRowArchived,
openSessions,
} from "../support/helpers/archive-tab";
import {
cloneGithubRepoDefaultBranchOnly,
createTempGithubRepo,
} from "../support/helpers/github-fixtures";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
} from "../support/helpers/new-workspace";
import { connectSeedClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
test.describe("Auto-archive after pull request merge", () => {
test.describe.configure({ retries: 0, timeout: 180_000 });
test("manual restore latches and keeps a merged pull request workspace active", async ({
page,
}) => {
const scenario = await createMergedPullRequestScenario();
try {
await scenario.autoArchive();
await openArchivedWorkspaceFromHistory(page, scenario.agentTitle);
await restoreWorkspace(page, scenario.workspaceId);
await scenario.refreshMergedPullRequest();
await scenario.expectWorkspaceRemainsActive();
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: scenario.workspaceId,
});
} finally {
await scenario.cleanup();
}
});
});
interface MergedPullRequestScenario {
agentTitle: string;
workspaceId: string;
autoArchive(): Promise<void>;
refreshMergedPullRequest(): Promise<void>;
expectWorkspaceRemainsActive(): Promise<void>;
cleanup(): Promise<void>;
}
async function createMergedPullRequestScenario(): Promise<MergedPullRequestScenario> {
const cleanups: Array<() => Promise<unknown>> = [];
try {
const repo = await createTempGithubRepo({
category: "auto-archive-latch",
prs: [{ title: "Auto-archive latch", state: "merged" }],
});
cleanups.push(() => repo.cleanup());
const checkout = await cloneGithubRepoDefaultBranchOnly(repo);
cleanups.push(() => checkout.cleanup());
const workspaceClient = await connectNewWorkspaceDaemonClient();
cleanups.push(() => workspaceClient.close());
const agentClient = await connectSeedClient();
cleanups.push(() => agentClient.close());
const previousConfig = await workspaceClient.getDaemonConfig();
await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: false });
cleanups.push(() =>
workspaceClient.patchDaemonConfig({
autoArchiveAfterMerge: previousConfig.config.autoArchiveAfterMerge,
}),
);
const pullRequest = repo.prs[0];
if (!pullRequest) {
throw new Error("Expected the merged pull request fixture");
}
const created = await workspaceClient.createPaseoWorktree({
cwd: checkout.path,
action: "checkout",
checkoutSource: {
kind: "change_request",
forge: "github",
number: pullRequest.number,
},
worktreeSlug: `auto-archive-latch-${Date.now()}`,
});
if (!created.workspace || created.error) {
throw new Error(created.error ?? "Failed to create merged pull request workspace");
}
const workspace = created.workspace;
cleanups.push(() => workspaceClient.removeProject(workspace.projectId));
cleanups.push(() => archiveWorkspaceFromDaemon(workspaceClient, workspace.workspaceDirectory));
const agentTitle = `Auto-archive latch ${Date.now()}`;
const agent = await agentClient.createAgent({
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
cwd: workspace.workspaceDirectory,
workspaceId: workspace.id,
title: agentTitle,
});
await agentClient.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle",
30_000,
);
return {
agentTitle,
workspaceId: workspace.id,
autoArchive: async () => {
await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: true });
await workspaceClient.checkoutRefresh(workspace.workspaceDirectory);
await expect
.poll(() => agentClient.fetchAgent({ agentId: agent.id }), { timeout: 30_000 })
.toMatchObject({ agent: { archivedAt: expect.any(String) } });
await expect
.poll(() => existsSync(workspace.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
},
refreshMergedPullRequest: async () => {
const refresh = await workspaceClient.checkoutRefresh(workspace.workspaceDirectory);
if (!refresh.success) {
throw new Error(`Failed to refresh restored workspace: ${String(refresh.error)}`);
}
},
expectWorkspaceRemainsActive: async () => {
const observationDeadline = Date.now() + 5_000;
await expect
.poll(
async () => {
const descriptor = (await workspaceClient.fetchWorkspaces()).entries.find(
(entry) => entry.id === workspace.id,
);
if (!descriptor) {
return "archived";
}
return Date.now() >= observationDeadline ? "active" : "observing";
},
{ timeout: 10_000, intervals: [100, 250, 500] },
)
.toBe("active");
},
cleanup: () => runScenarioCleanups(cleanups),
};
} catch (error) {
await runScenarioCleanups(cleanups);
throw error;
}
}
async function runScenarioCleanups(cleanups: Array<() => Promise<unknown>>): Promise<void> {
while (cleanups.length > 0) {
await cleanups
.pop()?.()
.catch(() => undefined);
}
}
async function openArchivedWorkspaceFromHistory(page: Page, agentTitle: string): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowArchived(page, agentTitle);
await clickSessionRow(page, agentTitle);
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
}
async function restoreWorkspace(page: Page, workspaceId: string): Promise<void> {
const action = page.getByTestId("workspace-recovery-action");
await expect(action).toHaveText("Restore");
await action.click();
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
}
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
@@ -123,4 +123,16 @@ test.describe("mobile bottom sheet reopen", () => {
await openAndCloseModelSelectorTwice(page);
});
});
test("model selector closes after model selection", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openModelSelector(page);
const sheet = page.getByLabel("Bottom Sheet", { exact: true });
await sheet.getByText("Ten second stream", { exact: true }).click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
});
});
});
@@ -1,15 +1,18 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
expectNoBranchSwitcherInWorkspaceHeader,
expectWorkspaceBranch,
openChangesPanel,
switchBranchFromChangesPanel,
} from "./helpers/branch-switcher";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { readWorktreeBranchInfo } from "./helpers/workspace";
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
} from "../support/helpers/branch-switcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { readWorktreeBranchInfo } from "../support/helpers/workspace";
import {
switchWorkspaceViaSidebar,
waitForSidebarHydration,
} from "../support/helpers/workspace-ui";
async function renameWorkspaceViaSidebar(
page: Page,
@@ -0,0 +1,241 @@
import { expect, test } from "../support/fixtures";
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
import {
clickChatOutlineRowEdge,
disableChatOutlineFromAppearance,
expectActiveChatOutlinePrompt,
expectChatOutlinePreview,
expectChatOutlinePrompts,
expectChatOutlineAlignedWithActiveTabGlyph,
expectChatOutlinePromptToRemainBare,
expectLiveTurnPromptAboveFoldAndActive,
expectNoChatOutlinePreviewWhileCrossingToSidebar,
expectNoChatOutline,
expectNoChatOutlinePreview,
expectOneActiveChatOutlinePrompt,
focusChatOutlinePrompt,
hoverChatOutlinePrompt,
chatOutlineRail,
movePointerOffChatOutline,
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
} from "../support/helpers/chat-outline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
expectTimelinePromptLandedBelowTop,
expectTimelinePromptVisible,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
scrollTimelineToNewestLoadedEdge,
scrollTimelineToOldestLoadedEdge,
seedLongMockAgentTimeline,
type LongTimelineAgent,
} from "../support/helpers/timeline-pagination";
const WIDE_VIEWPORT = { width: 1280, height: 900 };
const LOADED_TURNS = 16;
test.describe("desktop chat outline", () => {
test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const jumps = await trackPromptJumpRequests(page, agent.agentId);
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
const rail = chatOutlineRail(page);
await expectChatOutlinePrompts(page, 80);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await rail.getByRole("tab").nth(39).click();
await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
await expect.poll(() => jumps.requests()).toHaveLength(1);
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
await page.getByTestId("scroll-to-bottom-button").click();
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requestsBeforeLoadedJump = jumps.requests().length;
await rail.getByRole("tab").last().click();
await expectTimelinePromptVisible(page, agent.newestPrompt);
expect(jumps.requests()).toHaveLength(requestsBeforeLoadedJump);
await rail.getByRole("tab").first().click();
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await agent.client.sendAgentMessage(agent.agentId, "live prompt while viewing old history");
await agent.client.waitForFinish(agent.agentId, 15_000);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
test.describe("with the whole conversation loaded", () => {
let agent: LongTimelineAgent;
test.beforeAll(async () => {
test.setTimeout(120_000);
agent = await seedLongMockAgentTimeline({ turns: LOADED_TURNS });
});
test.afterAll(async () => {
await agent.cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, LOADED_TURNS);
});
test("previews the prompt under the pointer, anywhere across its row", async ({ page }) => {
await hoverChatOutlinePrompt(page, 2);
await expectChatOutlinePreview(page, agent.prompts[1]);
await pointAtChatOutlineRowEdge(page, 7);
await expectChatOutlinePreview(page, agent.prompts[6]);
await movePointerOffChatOutline(page);
await expectNoChatOutlinePreview(page);
});
test("does not preview while crossing the rail toward the sidebar", async ({ page }) => {
await expectNoChatOutlinePreviewWhileCrossingToSidebar(page);
});
test("closes the preview when the pointer leaves after a jump", async ({ page }) => {
await clickChatOutlineRowEdge(page, 4);
await movePointerOffChatOutline(page);
await expectNoChatOutlinePreview(page);
});
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
await focusChatOutlinePrompt(page, 1);
await expectChatOutlinePreview(page, agent.prompts[0]);
await pressEnterOnFocusedPrompt(page);
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
await expectActiveChatOutlinePrompt(page, 1);
});
test("moves the active mark as the reader jumps and scrolls", async ({ page }) => {
await expectOneActiveChatOutlinePrompt(page);
await clickChatOutlineRowEdge(page, 4);
await expectTimelinePromptVisible(page, agent.prompts[3]);
await expectActiveChatOutlinePrompt(page, 4);
await scrollTimelineToOldestLoadedEdge(page);
await expectActiveChatOutlinePrompt(page, 1);
});
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
await clickChatOutlineRowEdge(page, 4);
await expectChatOutlinePromptToRemainBare(page, 4);
});
test("aligns the ticks with the active tab glyph rail", async ({ page }) => {
await expectChatOutlineAlignedWithActiveTabGlyph(page);
});
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
page,
}) => {
await clickChatOutlineRowEdge(page, LOADED_TURNS);
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
});
test("keeps the current prompt active throughout a long live assistant turn", async ({
page,
}) => {
const prompt = "chat outline live turn reading position";
await agent.client.sendAgentMessage(agent.agentId, prompt);
try {
await expectLiveTurnPromptAboveFoldAndActive(page, prompt, LOADED_TURNS + 1);
} finally {
await agent.client.waitForFinish(agent.agentId, 15_000);
}
});
});
test.describe("with virtualized loaded history", () => {
let agent: LongTimelineAgent;
test.beforeAll(async () => {
test.setTimeout(120_000);
agent = await seedLongMockAgentTimeline({ turns: 80 });
});
test.afterAll(async () => {
await agent.cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize(WIDE_VIEWPORT);
});
test("pins a loaded prompt inside the virtualized block without fetching", async ({ page }) => {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await scrollThroughOlderHistoryPages(page, 2, history);
await scrollTimelineToNewestLoadedEdge(page);
const requestsBeforeJump = history.requestCount();
await clickChatOutlineRowEdge(page, 30);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
expect(history.requestCount()).toBe(requestsBeforeJump);
});
test("pins a newer loaded prompt when jumping downward from old history", async ({ page }) => {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await scrollThroughOlderHistoryPages(page, 2, history);
await scrollTimelineToNewestLoadedEdge(page);
await clickChatOutlineRowEdge(page, 30);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
await clickChatOutlineRowEdge(page, 60);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
});
});
test("hides the rail when a split makes its panel narrow", async ({ page }) => {
const agent = await seedLongMockAgentTimeline({ turns: 2 });
try {
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await splitCurrentPanelRight(page);
await expectNoChatOutline(page);
} finally {
await agent.cleanup();
}
});
test("can be disabled from Appearance settings", async ({ page }) => {
const agent = await seedLongMockAgentTimeline({ turns: 2 });
try {
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await disableChatOutlineFromAppearance(page);
await expectNoChatOutline(page);
} finally {
await agent.cleanup();
}
});
});
@@ -1,12 +1,12 @@
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expect, test, type Page } from "../support/fixtures";
import { composerLocator, expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectSessionRowArchived,
expectWorkspaceTabHidden,
expectWorkspaceTabVisible,
openSessions,
} from "./helpers/archive-tab";
} from "../support/helpers/archive-tab";
interface SlashCommandScenario {
agentId: string;
@@ -1,6 +1,6 @@
import { expect, test } from "./fixtures";
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expect, test } from "../support/fixtures";
import { allowPermission, waitForPermissionPrompt } from "../support/helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.describe("Codex plan approval", () => {
test("shows a single actionable plan panel and removes it after implementation starts", async ({
@@ -0,0 +1,110 @@
import { test } from "../support/fixtures";
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
submitDraftAgent,
waitForDraftComposer,
} from "../support/helpers/command-center-agent-controls";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise<void> {
await page.addInitScript(
({ preferencesKey, serverId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId,
provider: "mock",
providerPreferences: {
mock: {
model: "five-minute-stream",
mode: "load-test",
},
},
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() },
);
}
test.describe("Command Center agent controls", () => {
test.describe.configure({ timeout: 180_000 });
test("changes a running agent setting and preserves its selected row", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-live-controls-",
title: "Command Center live controls",
model: "five-minute-stream",
});
try {
// A stale runtime mode makes selecting the provider's supported mode
// exercise the live setting RPC instead of the selected-row no-op.
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "load test",
choice: "Mode › Load test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
await openCommandCenter(page);
await expectCommandCenterAgentControlSelected({
page,
query: "load test",
choice: "Mode › Load test",
});
} finally {
await workspace.cleanup();
}
});
test("applies draft model and setting choices to the created agent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
await seedMockDraftPreferences(page);
try {
await openDraftComposer(page, workspace.workspaceId);
await applyCommandCenterAgentControls(page, DRAFT_AGENT_CONTROL_CHOICES);
await closeCommandCenter(page);
await submitDraftAgent(page, "Create an agent with Command Center draft choices");
await expectFocusedAgentControls(page, DRAFT_AGENT_CONTROL_CHOICES);
await expectWorkspaceAgentConfiguration(workspace, {
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
});
const DRAFT_AGENT_CONTROL_CHOICES = [
{ query: "ten second stream", choice: "Model › Mock Load Test › Ten second stream" },
{ query: "load test", choice: "Mode › Load test" },
] as const;
async function openDraftComposer(page: import("@playwright/test").Page, workspaceId: string) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await waitForDraftComposer(page);
}
@@ -1,13 +1,13 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { expectAgentTabActive } from "./helpers/launcher";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
@@ -0,0 +1,53 @@
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
expectCommandCenterToRemainAtTheTop,
expectPrimaryCommandCenterActions,
expectTightTwoLineResultSpacing,
expectVisibleKeyboardNavigationNotToScroll,
expectWorkspaceResultsToBeLimitedUntilSearch,
observeCommandCenterScroll,
openCommandCenterWithKeyboard,
} from "../support/helpers/command-center-scroll";
import { seedWorkspace } from "../support/helpers/seed-client";
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("opening the command center keeps its first result fully visible", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-scroll-",
title: "Command Center Scroll 00",
});
try {
for (let index = 1; index <= 8; index += 1) {
const created = await seeded.client.createWorkspace({
source: {
kind: "worktree",
projectId: seeded.projectId,
baseBranch: "main",
worktreeSlug: `command-center-scroll-${index}`,
},
title: `Command Center Scroll ${String(index).padStart(2, "0")}`,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
}
}
await gotoAppShell(page);
await observeCommandCenterScroll(page);
const panel = await openCommandCenterWithKeyboard(page);
await expectCommandCenterToRemainAtTheTop(page);
await expectPrimaryCommandCenterActions(panel);
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
} finally {
await seeded.cleanup();
}
});
@@ -0,0 +1,34 @@
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import {
createTerminalFromCommandCenter,
expectDefaultWorkspaceActions,
expectWorkspaceActionsAvailableThroughSearch,
makeWorkspacePrimaryGitActionCommit,
openWorkspaceFromCommandCenter,
} from "../support/helpers/command-center-workspace-actions";
import { seedWorkspace } from "../support/helpers/seed-client";
test("workspace actions use the Git policy and dispatch through the active workspace", async ({
page,
}) => {
const title = "Command Center Workspace Actions";
const seeded = await seedWorkspace({
repoPrefix: "command-center-workspace-actions-",
title,
});
try {
await makeWorkspacePrimaryGitActionCommit(seeded);
await gotoAppShell(page);
await openWorkspaceFromCommandCenter(page, seeded, title);
const panel = await openCommandCenter(page);
await expectDefaultWorkspaceActions(panel);
await expectWorkspaceActionsAvailableThroughSearch(panel);
await createTerminalFromCommandCenter(page, panel);
} finally {
await seeded.cleanup();
}
});
@@ -1,13 +1,13 @@
import { execFileSync } from "node:child_process";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { expectAppRoute } from "./helpers/route-assertions";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
const PRIMARY_HOST_LABEL = "Primary Host";
@@ -55,9 +55,15 @@ test.describe("Command center workspaces", () => {
);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(WORKSPACE_TITLE);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
await expect(row).toContainText(WORKSPACE_BRANCH);
// The subtitle disambiguates by project: host · project · branch (multi-host).
const subtitle = row.getByTestId("command-center-workspace-subtitle");
await expect(subtitle).toContainText(PRIMARY_HOST_LABEL);
await expect(subtitle).toContainText(seeded.projectDisplayName);
await expect(subtitle).toContainText(WORKSPACE_BRANCH);
// The agent subtitle is unchanged by the shared-helper refactor.
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(agentRow).toContainText(AGENT_TITLE);
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
@@ -89,6 +95,10 @@ test.describe("Command center workspaces", () => {
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
// The project name is now part of the workspace searchText.
await input.fill(seeded.projectDisplayName);
await expect(row).toBeVisible();
await input.fill(AGENT_TITLE);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
@@ -103,4 +113,38 @@ test.describe("Command center workspaces", () => {
await seeded.cleanup();
}
});
test("single-host workspace subtitle omits the host and shows project · branch", async ({
page,
}) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-workspace-single-",
title: WORKSPACE_TITLE,
});
try {
execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], {
cwd: seeded.repoPath,
stdio: "ignore",
});
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
}
// No secondary host: with a single host, the host label is gated away.
await gotoAppShell(page);
const panel = await openCommandCenter(page);
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
);
await expect(row).toBeVisible({ timeout: 30_000 });
const subtitle = row.getByTestId("command-center-workspace-subtitle");
await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`);
} finally {
await seeded.cleanup();
}
});
});
@@ -1,10 +1,29 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "./fixtures";
import { test, expect } from "../support/fixtures";
const COMMIT_SUBJECT = "Show commit timestamps";
test("commit history explains when the workspace has no commits ahead of its base", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "commit-history-empty-workspace-" });
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
await expect(page.getByTestId("commits-section-no-workspace-commits")).toHaveText(
"No commits ahead of main yet",
{ timeout: 30_000 },
);
});
test("commit history shows dates and shares diff layout preferences", async ({
page,
withWorkspace,
@@ -23,31 +42,26 @@ test("commit history shows dates and shares diff layout preferences", async ({
hasText: COMMIT_SUBJECT,
});
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
await expect(page.locator('[data-testid^="commit-row-"]')).toHaveCount(1);
await expect(commitRow).toContainText("Jan 15");
await commitRow.click();
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
"aria-selected",
"true",
);
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await panel.getByTestId("commit-diff-layout-split").click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
);
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
"Switch to unified diff",
{ timeout: 30_000 },
);
@@ -1,7 +1,7 @@
import { expect, test } from "./fixtures";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible } from "./helpers/composer";
import { expectAgentIdle } from "./helpers/agent-stream";
import { expect, test } from "../support/fixtures";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import {
openAttachmentMenu,
openGithubPickerFromMenu,
@@ -23,13 +23,19 @@ import {
selectGithubOption,
expectGithubAttachmentPill,
openGithubWorkspace,
} from "./helpers/composer";
import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { seedWorkspace } from "./helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id";
} from "../support/helpers/composer";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import { gotoAppShell } from "../support/helpers/app";
import {
waitForSidebarHydration,
switchWorkspaceViaSidebar,
} from "../support/helpers/workspace-ui";
import { seedWorkspace } from "../support/helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
@@ -144,11 +150,6 @@ test.describe("Composer attachments", () => {
// needed before this can be exercised end-to-end.
});
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
});
test("image lightbox opens on pill click and closes on Escape", async ({
page,
withWorkspace,
@@ -207,7 +208,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
@@ -293,7 +294,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
@@ -1,14 +1,14 @@
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import { expect, test, type Page } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
} from "../support/helpers/mock-agent";
import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/archive-tab";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
const TEST_COMMANDS = [
{
@@ -1,15 +1,20 @@
import { writeFile } from "node:fs/promises";
import { unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
import { test, expect } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
repoPath: string;
}
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
}
interface CleanupTask {
@@ -218,6 +223,99 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTabToggle = page.getByTestId("changes-open-tab");
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
await changesTabToggle.click();
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
await page.getByTestId("working-diff-toggle-wrap-lines").click();
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
"Scroll long lines",
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(visiblePanel).toContainText("zz-deleted.ts");
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
"zz-deleted.ts",
);
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -243,6 +341,11 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -250,6 +353,53 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expectFlatFileList(page);
});
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -399,10 +549,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
});
}
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
async function createWorkspaceWithMountedTabDiff(
options: WorkspaceFixtureOptions = {},
): Promise<DirtyWorkspace> {
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
const repo = await createTempGitRepo("diff-row-alignment-", { files });
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
@@ -412,13 +566,16 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
}
return { id: createdWorkspace.workspace.id };
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -436,6 +593,21 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
@@ -1,5 +1,5 @@
import { test } from "./fixtures";
import { DirectoryBootstrapScenario } from "./helpers/directory-bootstrap-scenario";
import { test } from "../support/fixtures";
import { DirectoryBootstrapScenario } from "../support/helpers/directory-bootstrap-scenario";
test.describe("Directory bootstrap correctness", () => {
test("connect, pushed deltas, and reconnect keep directories current without duplicate bootstraps", async ({
@@ -1,17 +1,18 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
addProjectFlowInput,
chooseAddProjectMethod,
openAddProjectFlow,
} from "./helpers/add-project-flow";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
} from "../support/helpers/add-project-flow";
import { expectOpenedProject } from "../support/helpers/project-picker-ui";
import { connectSeedClient, seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { createTempGitRepo } from "../support/helpers/workspace";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
@@ -32,12 +33,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
async function removeProjectFromSidebar(page: Page, projectViewKey: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
const kebab = page.getByTestId(`sidebar-project-kebab-${projectViewKey}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
@@ -45,7 +46,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
// user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectViewKey}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click();
}
@@ -152,9 +153,10 @@ test.describe("Project with no workspaces persists", () => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectViewKey = projectEquivalenceViewKey(workspace.projectKey);
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`);
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectId}`,
`sidebar-project-new-workspace-row-${projectViewKey}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -195,9 +197,11 @@ test.describe("Project with no workspaces persists", () => {
test.describe("Project remove", () => {
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
let readdedProjectId: string | null = null;
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectViewKey = projectEquivalenceViewKey(workspace.projectKey);
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -206,7 +210,7 @@ test.describe("Project remove", () => {
timeout: 30_000,
});
await removeProjectFromSidebar(page, workspace.projectId);
await removeProjectFromSidebar(page, projectViewKey);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
@@ -220,21 +224,26 @@ test.describe("Project remove", () => {
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
const readdedProjectId = readded.project?.projectId ?? "";
readdedProjectId = readded.project?.projectId ?? "";
const readdedProjectKey = readded.project?.projectKey ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readdedProjectKey).toBe(workspace.projectKey);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
page.getByTestId(
`sidebar-project-new-workspace-row-${projectEquivalenceViewKey(readdedProjectKey)}`,
),
).toBeVisible({ timeout: 30_000 });
} finally {
if (readdedProjectId) {
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
}
await workspace.cleanup();
}
});
@@ -0,0 +1,363 @@
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import {
expectFileTabOpen,
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import type { WithWorkspace } from "../support/helpers/with-workspace";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectFileCalloutWasRendered,
expectNoFileCalloutWasRendered,
recordFileCallouts,
} from "../support/helpers/file-callouts";
function visibleEditor(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function filePane(page: Page) {
return page.getByTestId("workspace-file-pane").filter({ visible: true });
}
function fileCallout(page: Page) {
return filePane(page).getByRole("alert");
}
async function openFile(page: Page, filename: string): Promise<void> {
await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expectFileTabOpen(page, filename);
}
async function openTrackedFile(
page: Page,
withWorkspace: WithWorkspace,
input: { prefix: string; relativePath: string; content: string | Uint8Array },
): Promise<string> {
const workspace = await withWorkspace({ prefix: input.prefix });
const filePath = path.join(workspace.repoPath, input.relativePath);
if (typeof input.content === "string") {
await writeFile(filePath, input.content, "utf8");
} else {
await writeFile(filePath, input.content);
}
await workspace.navigateTo();
await openFile(page, input.relativePath);
return filePath;
}
async function replaceEditorText(page: Page, content: string): Promise<void> {
const editor = visibleEditor(page);
await editor.click();
await editor.press("Control+A");
await editor.type(content);
}
async function replaceFileAfterDeletionWasObserved(input: {
page: Page;
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
filePath: string;
relativePath: string;
content: string;
}): Promise<void> {
const { page, gate, filePath, relativePath, content } = input;
// Recreate the observed race without faking file state: the daemon publishes the
// deletion, then a real read succeeds after the replacement while its ready event waits.
await gate.waitForFileSubscription(relativePath);
gate.holdFileReads(relativePath);
await unlink(filePath);
await gate.waitForHeldFileRead();
await expect(fileCallout(page)).toHaveCount(0);
gate.releaseHeldFileRead();
await expectOnlyFileCallout(page, "File deleted on disk");
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(filePath, content, "utf8");
await gate.waitForHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
}
async function expectOnlyFileCallout(page: Page, title: string): Promise<void> {
await expect(fileCallout(page)).toHaveCount(1);
await expect(fileCallout(page)).toContainText(title);
await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1);
}
async function expectCleanReplacementCanReload(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "Changed on disk");
await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
}
async function expectDeletedFileNotice(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "File deleted on disk");
await expect(fileCallout(page)).toContainText("The open copy is preserved.");
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
}
async function expectDirtyConflictCanReload(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "Changed on disk");
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toBeEnabled();
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
page.once("dialog", (dialog) => dialog.accept());
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
await expect(visibleEditor(page)).toContainText("const external = true;");
await expect(fileCallout(page)).toHaveCount(0);
}
async function restoreFileAfterWatcherObservedTemporaryAbsence(input: {
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
filePath: string;
relativePath: string;
}): Promise<void> {
const parkedPath = `${input.filePath}.saving`;
input.gate.holdFileReads(input.relativePath);
await rename(input.filePath, parkedPath);
await input.gate.waitForFileUpdate(input.relativePath, "missing");
await input.gate.waitForHeldFileRead();
await writeFile(input.filePath, await readFile(parkedPath));
await unlink(parkedPath);
input.gate.releaseHeldFileRead();
}
test.describe("Workspace file change conflicts", () => {
test("a temporary write gap never renders a disk-change callout", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "temporary-gap.ts";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-temporary-gap-",
relativePath,
content: "const preserved = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
await replaceEditorText(page, "const local = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
});
test("an identical atomic rewrite never renders a disk-change callout", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "identical-rewrite.ts";
const content = "const preserved = true;\n";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-identical-rewrite-",
relativePath,
content,
});
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(`${filePath}.tmp`, content, "utf8");
await rename(`${filePath}.tmp`, filePath);
await gate.waitForHeldReadyFileUpdate();
await replaceEditorText(page, "const local = true;\n");
gate.releaseHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
});
test("a save watcher refresh never replays the pre-save buffer", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "save-refresh.ts";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-save-refresh-",
relativePath,
content: "const before = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
gate.holdFileReads(relativePath);
await replaceEditorText(page, "const saved = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
await gate.waitForHeldFileRead();
await expect(visibleEditor(page)).toContainText("const saved = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
gate.releaseHeldFileRead();
});
test("a clean file replaced on disk offers one working reload action", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-clean-replacement-",
relativePath: "inventory.md",
content: "# Before\n",
});
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await replaceFileAfterDeletionWasObserved({
page,
gate,
filePath,
relativePath: "inventory.md",
content: "# After\n",
});
gate.releaseHeldReadyFileUpdate();
await expectCleanReplacementCanReload(page);
});
test("a deleted file shows one explanatory notice and no resolution actions", async ({
page,
withWorkspace,
}) => {
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-deleted-",
relativePath: "deleted.md",
content: "# Present\n",
});
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
await recordFileCallouts(page);
await unlink(filePath);
await expectDeletedFileNotice(page);
await expectFileCalloutWasRendered(page, "File deleted on disk");
});
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "retry.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-retry-",
relativePath,
content: "# Before\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled();
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, "# After\n", "utf8");
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled();
gate.releaseHeldFileRead();
await expectCleanReplacementCanReload(page);
gate.releaseHeldReadyFileUpdate();
});
test("retry clears a transient error when the file is unchanged", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "unchanged.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-unchanged-",
relativePath,
content: "# Unchanged\n",
});
const parkedPath = `${filePath}.parked`;
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible();
await rename(filePath, parkedPath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await rename(parkedPath, filePath);
await gate.waitForHeldReadyFileUpdate();
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await expect(fileCallout(page)).toHaveCount(0);
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a read-only file error preserves its message and offers retry", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "artifact.bin";
const binaryContent = new Uint8Array([0, 1, 2, 3, 255]);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-read-only-retry-",
relativePath,
content: binaryContent,
});
await gate.waitForFileSubscription(relativePath);
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toBeVisible();
await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled();
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, binaryContent);
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await filePane(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldFileRead();
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a changed file with local edits offers overwrite and a working reload", async ({
page,
withWorkspace,
}) => {
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-dirty-conflict-",
relativePath: "source.ts",
content: "const before = true;\n",
});
await replaceEditorText(page, "const local = true;\n");
await writeFile(filePath, "const external = true;\n", "utf8");
await expectDirtyConflictCanReload(page);
});
});
@@ -1,9 +1,13 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "./fixtures";
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expect, test, type Page } from "../support/fixtures";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const RED_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
@@ -13,11 +17,26 @@ const BLUE_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const BLOCKED_PREVIEW_URL = "https://html-preview.invalid/leak";
interface LinkedFile {
target: string;
fileName: string;
content: string;
}
function editor(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function hasHorizontalOverflow(element: HTMLElement): boolean {
return element.scrollWidth > element.clientWidth;
}
function fitsViewportWidth(element: HTMLElement): boolean {
return element.scrollWidth === element.clientWidth;
}
async function replaceEditorText(page: Page, content: string): Promise<void> {
const contentElement = editor(page);
await contentElement.click();
@@ -32,7 +51,31 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
await expectFileTabOpen(page, filename);
}
async function seedAgentWithFileLink(target: string) {
function htmlPreview(page: Page) {
return {
host: page.getByTestId("file-html-preview"),
document: page.frameLocator('[data-testid="file-html-preview"]'),
};
}
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view,
exact: true,
});
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
function watchRequestsTo(page: Page, origin: string): string[] {
const requests: string[] = [];
page.on("request", (request) => {
if (request.url().startsWith(origin)) requests.push(request.url());
});
return requests;
}
async function seedAgentWithFileLink(input: LinkedFile) {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-editing-chat-link-",
title: "Chat file link e2e",
@@ -41,24 +84,25 @@ async function seedAgentWithFileLink(target: string) {
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
`Open \`${input.target}\` now`,
"</user-prompt>",
].join("\n"),
});
await writeFile(
path.join(session.cwd, "target.ts"),
Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join(
"\n",
),
"utf8",
);
await writeFile(path.join(session.cwd, input.fileName), input.content, "utf8");
return session;
}
test.describe("CodeMirror workspace file editing", () => {
test("opens an assistant file link at its referenced line", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink(target);
const session = await seedAgentWithFileLink({
target,
fileName: "target.ts",
content: Array.from(
{ length: 80 },
(_, index) => `export const line${index + 1} = ${index + 1};`,
).join("\n"),
});
try {
await openAgentRoute(page, session);
@@ -95,6 +139,66 @@ test.describe("CodeMirror workspace file editing", () => {
}
});
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink({
target,
fileName: "target.ts",
content: Array.from(
{ length: 80 },
(_, index) => `export const line${index + 1} = ${index + 1};`,
).join("\n"),
});
try {
await page.setViewportSize({ width: 1280, height: 900 });
await openAgentRoute(page, session);
await page.getByRole("button", { name: "Split pane right" }).first().click();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await openWorkspaceFile(page, "target.ts");
await page
.getByTestId(`workspace-tab-agent_${session.agentId}`)
.filter({ visible: true })
.click();
await editor(page).click();
await page.keyboard.press("Alt+Shift+W");
await expect(page.getByTestId("workspace-tab-file_target.ts")).not.toBeVisible();
await expect(
page.getByTestId(`workspace-tab-agent_${session.agentId}`).filter({ visible: true }),
).toBeVisible();
} finally {
await session.cleanup();
}
});
test("opens an HTML line target as source", async ({ page }) => {
const target = "plan.html:2";
const session = await seedAgentWithFileLink({
target,
fileName: "plan.html",
content: [
"<!doctype html>",
"<h1>Review this source line</h1>",
'<script>document.body.textContent = "This HTML executed";</script>',
].join("\n"),
});
try {
await openAgentRoute(page, session);
await page.getByText(target, { exact: true }).click();
await expectFileTabOpen(page, "plan.html");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByLabel("Line 2, column 1")).toBeVisible();
await expect(page.getByTestId("file-html-preview")).toHaveCount(0);
} finally {
await session.cleanup();
}
});
test("shows the full file path and keeps editor controls stable", async ({
page,
withWorkspace,
@@ -124,9 +228,9 @@ test.describe("CodeMirror workspace file editing", () => {
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
const modeControl = page.getByTestId("file-markdown-mode");
const modeControl = page.getByTestId("file-preview-mode");
await expect(modeControl).toBeVisible();
await page.getByTestId("file-mode-source").click();
await selectFileView(page, "Source");
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
@@ -174,6 +278,36 @@ test.describe("CodeMirror workspace file editing", () => {
).toHaveCSS("font-family", "monospace");
});
test("wraps Markdown while source code remains horizontally scrollable", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "file-editing-wrap-" });
const longLine = "word ".repeat(300);
await writeFile(path.join(workspace.repoPath, "notes.md"), `${longLine}\n`, "utf8");
await writeFile(
path.join(workspace.repoPath, "source.ts"),
`const value = "${longLine}";\n`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await selectFileView(page, "Source");
const markdownScroller = page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-scroller");
await expect.poll(() => markdownScroller.evaluate(fitsViewportWidth)).toBe(true);
await openWorkspaceFile(page, "source.ts");
const sourceScroller = page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-scroller");
await expect.poll(() => sourceScroller.evaluate(hasHorizontalOverflow)).toBe(true);
});
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
page,
withWorkspace,
@@ -239,6 +373,29 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(editor(page)).toContainText("const afterReconnect = 9;");
});
test("preserves a UTF-8 BOM and uses the first line separator after saving", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "file-editing-encoding-" });
const sourcePath = path.join(workspace.repoPath, "windows.ts");
await writeFile(
sourcePath,
Buffer.from("\uFEFFconst initial = true;\r\nconst mixed = true;\n", "utf8"),
);
await workspace.navigateTo();
await openWorkspaceFile(page, "windows.ts");
await replaceEditorText(page, "const saved = true;\nconst normalized = true;\n");
await editor(page).press("Control+s");
const expected = Buffer.from(
"\uFEFFconst saved = true;\r\nconst normalized = true;\r\n",
"utf8",
).toString("hex");
await expect.poll(async () => (await readFile(sourcePath)).toString("hex")).toBe(expected);
});
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
const sourcePath = path.join(workspace.repoPath, "draft.ts");
@@ -285,15 +442,15 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "notes.md");
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
await page.getByTestId("file-mode-source").click();
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await page.getByTestId("file-mode-preview").click();
await selectFileView(page, "Preview");
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
await openWorkspaceFile(page, "pixel.png");
@@ -304,6 +461,110 @@ test.describe("CodeMirror workspace file editing", () => {
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
});
test("previews and refreshes an HTML plan while preserving source access", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-preview-" });
const htmlPath = path.join(workspace.repoPath, "plan.html");
await writeFile(
htmlPath,
"<!doctype html><html><body><h1>Visual plan</h1></body></html>",
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "plan.html");
const preview = htmlPreview(page);
await expect(preview.host).toBeVisible();
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
await writeFile(
htmlPath,
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
"utf8",
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
await selectFileView(page, "Preview");
await expect(preview.host).toBeVisible();
});
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
await writeFile(
path.join(workspace.repoPath, "probe.html"),
`<!doctype html><html><head><title>probe</title></head><body>
<h1 id="script-result">Inline script did not run</h1>
<p id="network-result">Network request not attempted</p>
<p id="document-mode">Standards mode not detected</p>
<script>
document.getElementById("script-result").textContent = "Inline script ran";
if (document.compatMode === "CSS1Compat") {
document.getElementById("document-mode").textContent = "Standards mode enabled";
}
var networkResult = document.getElementById("network-result");
fetch("${BLOCKED_PREVIEW_URL}", { method: "POST", body: "repo-content" })
.then(function () { networkResult.textContent = "Network request allowed"; })
.catch(function () { networkResult.textContent = "Network request blocked"; });
</script>
</body></html>`,
"utf8",
);
await workspace.navigateTo();
const blockedRequests = watchRequestsTo(page, BLOCKED_PREVIEW_URL);
await openWorkspaceFile(page, "probe.html");
const preview = htmlPreview(page);
await expect(
preview.document.getByRole("heading", { name: "Inline script ran" }),
).toBeVisible();
await expect(
preview.document.getByText("Standards mode enabled", { exact: true }),
).toBeVisible();
await expect(
preview.document.getByText("Network request blocked", { exact: true }),
).toBeVisible();
expect(blockedRequests).toEqual([]);
});
test("isolates HTML plans from the app origin and storage", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-origin-" });
await writeFile(
path.join(workspace.repoPath, "origin.html"),
`<!doctype html><html><body>
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
<script>
function report(id, label, probe) {
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
}
report("parent", "Parent DOM", function () { return parent.document.body; });
report("storage", "Storage", function () { return localStorage.length; });
report("cookie", "Cookies", function () { return document.cookie; });
</script>
</body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "origin.html");
const preview = htmlPreview(page);
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
});
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
page,
withWorkspace,
@@ -1,4 +1,4 @@
import { test } from "./fixtures";
import { test } from "../support/fixtures";
import {
collapseFolder,
expandFolder,
@@ -7,9 +7,9 @@ import {
expectFileTabOpen,
openFileExplorer,
openFileFromExplorer,
} from "./helpers/file-explorer";
import { gotoWorkspace } from "./helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
} from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
@@ -0,0 +1,213 @@
import { expect, test as base } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
addConnectedHostAndReload,
chooseHostBadgeDisplay,
chooseHostColor,
expectHostAppearancePreview,
expectHostBadgeIconOnly,
expectHostBadgeName,
expectHostBadgeTinted,
expectNoHostBadge,
leaveHostAppearanceSettings,
openHostAppearanceSettings,
reloadPreservingHostRegistry,
renameHostFromSettings,
waitForConnectedHost,
} from "../support/helpers/hosts";
import {
type IsolatedHostDaemon,
startIsolatedHostDaemon,
} from "../support/helpers/isolated-host-daemon";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_LABEL = "Secondary Host";
interface TwoHostSidebar {
primaryServerId: string;
primaryWorkspaceId: string;
secondaryServerId: string;
secondaryWorkspaceId: string;
}
// The host badge only appears once the visible sidebar spans more than one host, and that count
// runs over visible projects — so an offline host with no workspaces will not raise it. Two real
// daemons, each with a seeded workspace, is the minimum shape that exercises the feature.
const test = base.extend<{ twoHostSidebar: TwoHostSidebar }>({
twoHostSidebar: async ({ page }, provide) => {
const secondaryHost: IsolatedHostDaemon = await startIsolatedHostDaemon(
"host-appearance-secondary",
);
let primary: SeededWorkspace | null = null;
let secondary: SeededWorkspace | null = null;
try {
primary = await seedWorkspace({
repoPrefix: "host-appearance-primary-",
title: "Primary workspace",
});
secondary = await seedWorkspace({
repoPrefix: "host-appearance-secondary-",
title: "Secondary workspace",
port: secondaryHost.port,
});
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
primaryLabel: PRIMARY_HOST_LABEL,
});
await waitForConnectedHost(page, {
serverId: secondaryHost.serverId,
endpoint: `localhost:${secondaryHost.port}`,
});
await waitForSidebarHydration(page);
await provide({
primaryServerId: getServerId(),
primaryWorkspaceId: primary.workspaceId,
secondaryServerId: secondaryHost.serverId,
secondaryWorkspaceId: secondary.workspaceId,
});
} finally {
await primary?.cleanup().catch(() => undefined);
await secondary?.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
});
test.describe("Host appearance", () => {
test.describe.configure({ timeout: 180_000 });
test("renaming a host renames its sidebar badge", async ({ page, twoHostSidebar }) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
};
await expectHostBadgeName(page, { ...badge, hostName: SECONDARY_HOST_LABEL });
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, "Build Box");
await leaveHostAppearanceSettings(page);
await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
});
test("long host names leave room for the workspace title and actions", async ({
page,
twoHostSidebar,
}) => {
const hostName = "A very long remote build host name that must not take over the sidebar";
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, hostName);
await leaveHostAppearanceSettings(page);
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
await expect(badge).toHaveText(hostName);
await row.hover();
const title = row.getByText("Secondary workspace", { exact: true });
const kebab = page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`);
await expect(kebab).toBeVisible();
const [badgeBox, titleBox, rowBox, kebabBox] = await Promise.all([
badge.boundingBox(),
title.boundingBox(),
row.boundingBox(),
kebab.boundingBox(),
]);
expect(badgeBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(rowBox).not.toBeNull();
expect(kebabBox).not.toBeNull();
await expect(badge).toHaveCSS("flex-shrink", "0");
expect(titleBox!.width).toBeGreaterThan(0);
expect(kebabBox!.x + kebabBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
});
test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: SECONDARY_HOST_LABEL,
};
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await chooseHostColor(page, "Teal");
await leaveHostAppearanceSettings(page);
await expectHostBadgeTinted(page, { ...badge, color: "teal" });
});
test("icon only keeps the badge and drops the name", async ({ page, twoHostSidebar }) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: SECONDARY_HOST_LABEL,
};
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await chooseHostBadgeDisplay(page, "Icon only");
await leaveHostAppearanceSettings(page);
await expectHostBadgeIconOnly(page, badge);
});
test("hiding one host leaves the other host's badge visible", async ({
page,
twoHostSidebar,
}) => {
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await chooseHostBadgeDisplay(page, "Hidden");
await leaveHostAppearanceSettings(page);
await expectNoHostBadge(page, {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: SECONDARY_HOST_LABEL,
});
await expectHostBadgeName(page, {
serverId: twoHostSidebar.primaryServerId,
workspaceId: twoHostSidebar.primaryWorkspaceId,
hostName: PRIMARY_HOST_LABEL,
});
});
test("name, color, and display survive a reload", async ({ page, twoHostSidebar }) => {
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, "Build Box");
await chooseHostColor(page, "Amber");
await chooseHostBadgeDisplay(page, "Icon only");
await leaveHostAppearanceSettings(page);
await reloadPreservingHostRegistry(page);
await waitForSidebarHydration(page);
await expectHostBadgeIconOnly(page, {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: "Build Box",
});
});
test("the settings preview shows the badge as configured", async ({ page, twoHostSidebar }) => {
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, "Build Box");
await chooseHostColor(page, "Emerald");
await expectHostAppearancePreview(page, {
serverId: twoHostSidebar.secondaryServerId,
hostName: "Build Box",
color: "emerald",
});
});
});
@@ -2,14 +2,14 @@ import { randomUUID } from "node:crypto";
import path from "node:path";
import { spawn } from "node:child_process";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { connectSeedClient, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { expect, test, type Page } from "../support/fixtures";
import { connectSeedClient, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const OPENCODE_REAL_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const OPENCODE_SEED_TIMEOUT_MS = 45_000;
const PASEO_REPO_PATH = path.resolve(__dirname, "../../..");
const PASEO_REPO_PATH = path.resolve(__dirname, "../../../..");
interface OpenCodeSeedResult {
stdout: string;
@@ -64,6 +64,13 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const descriptor = (await client.listProjects()).projects.find(
(project) => project.projectId === createdWorkspace.workspace?.projectId,
);
const projectKey = descriptor?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,
promptPreview,
@@ -75,6 +82,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectKey,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
@@ -1,4 +1,4 @@
import { test, expect } from "./fixtures";
import { test, expect } from "../support/fixtures";
import {
gotoWorkspace,
assertNewChatTileVisible,
@@ -13,10 +13,13 @@ import {
measureTileTransition,
sampleTabsDuringTransition,
terminalSurfaceLocator,
} from "./helpers/launcher";
import { expectComposerVisible, composerLocator } from "./helpers/composer";
import { expectTerminalSurfaceVisible, setupDeterministicPrompt } from "./helpers/terminal-perf";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
} from "../support/helpers/launcher";
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
import {
expectTerminalSurfaceVisible,
setupDeterministicPrompt,
} from "../support/helpers/terminal-perf";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
// ─── Shared state ──────────────────────────────────────────────────────────
@@ -1,17 +1,17 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { openAgentRoute } from "./helpers/mock-agent";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { expectNoTruncation } from "./helpers/no-truncation";
import { escapeRegex } from "./helpers/regex";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
} from "../support/helpers/new-workspace";
import { expectNoTruncation } from "../support/helpers/no-truncation";
import { escapeRegex } from "../support/helpers/regex";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
@@ -88,7 +88,7 @@ async function readCodexModePreference(page: Page): Promise<unknown> {
}
async function selectMode(page: Page, label: string): Promise<void> {
const modeControl = page.getByTestId("mode-control").first();
const modeControl = page.getByRole("button", { name: /^Select agent mode \(/ });
await expect(modeControl).toBeVisible({ timeout: 30_000 });
await modeControl.click();
@@ -100,11 +100,7 @@ async function selectMode(page: Page, label: string): Promise<void> {
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(label);
const option = page
.getByRole("dialog")
.last()
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
.first();
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click({ force: true });
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
@@ -169,16 +165,18 @@ test.describe("New workspace Codex mode preferences", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await expect(
page.getByRole("button", { name: "Select agent mode (Default permissions)" }),
).toBeVisible({ timeout: 30_000 });
await expectThinkingOptionsFit(page);
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
await expect(
page.getByRole("button", { name: "Select agent mode (Full access)" }),
).toBeVisible();
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
@@ -214,24 +212,24 @@ test.describe("New workspace Codex mode preferences", () => {
workspaceId: seeded.workspaceId,
agentId: agent.id,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await expect(
page.getByRole("button", { name: "Select agent mode (Default permissions)" }),
).toBeVisible({ timeout: 30_000 });
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
timeout: 30_000,
});
await expect(
page.getByRole("button", { name: "Select agent mode (Full access)" }),
).toBeVisible({ timeout: 30_000 });
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
timeout: 30_000,
});
await expect(
page.getByRole("button", { name: "Select agent mode (Full access)" }),
).toBeVisible({ timeout: 30_000 });
} finally {
await seeded.cleanup();
}
@@ -1,6 +1,6 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
expectNewWorkspaceDraft,
expectNewWorkspaceProjectSelected,
@@ -9,11 +9,11 @@ import {
openNewWorkspaceComposer,
selectNewWorkspaceHost,
selectNewWorkspaceProject,
} from "./helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const DRAFT = `Please investigate the workspace startup failure.
@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectId,
projectKey: firstProject.projectKey,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectId,
projectKey: secondProject.projectKey,
projectDisplayName: secondProject.projectDisplayName,
});
@@ -0,0 +1,198 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
openNewWorkspaceComposer,
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
const TRANSCRIPT = "Keep this spoken prompt visible while creating the workspace";
const CREATE_FAILURE = "Synthetic workspace creation failure";
function parseEnvelope(message: WebSocketMessage): {
type?: unknown;
message?: Record<string, unknown>;
} | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw) as { type?: unknown; message?: Record<string, unknown> };
} catch {
return null;
}
}
function sessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseEnvelope(message);
return envelope?.type === "session" && envelope.message ? envelope.message : null;
}
function sendSessionMessage(
ws: { send(message: string): void },
message: Record<string, unknown>,
): void {
ws.send(JSON.stringify({ type: "session", message }));
}
function enableDictationCapability(message: WebSocketMessage): WebSocketMessage {
const envelope = parseEnvelope(message);
const payload = envelope?.message?.payload;
if (
envelope?.message?.type !== "status" ||
!payload ||
typeof payload !== "object" ||
(payload as { status?: unknown }).status !== "server_info"
) {
return message;
}
(payload as Record<string, unknown>).capabilities = {
voice: {
dictation: { enabled: true, reason: "" },
voice: { enabled: false, reason: "Realtime voice is disabled in this test." },
},
};
return JSON.stringify(envelope);
}
async function installSyntheticMicrophone(page: Page): Promise<void> {
await page.addInitScript(() => {
const mediaDevices = navigator.mediaDevices;
Object.defineProperty(mediaDevices, "getUserMedia", {
configurable: true,
value: async () => {
const context = new AudioContext();
const oscillator = context.createOscillator();
const destination = context.createMediaStreamDestination();
oscillator.connect(destination);
oscillator.start();
return destination.stream;
},
});
});
}
async function installDictationFailureHarness(page: Page) {
let resolveAudioChunk!: () => void;
let resolveCreateRequest!: () => void;
const audioChunk = new Promise<void>((resolve) => {
resolveAudioChunk = resolve;
});
const createRequest = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
let failCreate: (() => void) | null = null;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const request = sessionMessage(message);
const type = request?.type;
const dictationId = typeof request?.dictationId === "string" ? request.dictationId : null;
if (type === "dictation_stream_start" && dictationId) {
sendSessionMessage(ws, {
type: "dictation_stream_ack",
payload: { dictationId, ackSeq: -1 },
});
return;
}
if (type === "dictation_stream_chunk" && dictationId) {
const seq = typeof request?.seq === "number" ? request.seq : 0;
sendSessionMessage(ws, {
type: "dictation_stream_ack",
payload: { dictationId, ackSeq: seq },
});
resolveAudioChunk();
return;
}
if (type === "dictation_stream_finish" && dictationId) {
sendSessionMessage(ws, {
type: "dictation_stream_finish_accepted",
payload: { dictationId, timeoutMs: 5_000 },
});
sendSessionMessage(ws, {
type: "dictation_stream_final",
payload: { dictationId, text: TRANSCRIPT },
});
return;
}
if (request && type === "workspace.create.request" && typeof request.requestId === "string") {
const requestId = request.requestId;
failCreate = () => {
sendSessionMessage(ws, {
type: "workspace.create.response",
payload: {
requestId,
workspace: null,
setupTerminalId: null,
error: CREATE_FAILURE,
},
});
};
resolveCreateRequest();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(enableDictationCapability(message)));
});
return {
waitForAudio: () => audioChunk,
waitForCreateRequest: () => createRequest,
failWorkspaceCreation: () => {
if (!failCreate) {
throw new Error("Workspace creation request has not been received");
}
failCreate();
},
};
}
async function dictateAndSend(page: Page, waitForAudio: () => Promise<void>): Promise<void> {
await page.getByRole("button", { name: "Start dictation" }).click();
await waitForAudio();
await page.getByRole("button", { name: "Insert transcription and send" }).click();
}
test.describe("New Workspace dictation submit", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps the spoken prompt visible while pending and after failure", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "dictation-submit-" });
await installSyntheticMicrophone(page);
const harness = await installDictationFailureHarness(page);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await selectWorkspaceIsolation(page, "local");
await dictateAndSend(page, harness.waitForAudio);
await harness.waitForCreateRequest();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toHaveValue(TRANSCRIPT);
await expect(composer).not.toBeEditable();
harness.failWorkspaceCreation();
await expect(composer).toBeEditable();
await expect(composer).toHaveValue(TRANSCRIPT);
await expect(page.getByText(CREATE_FAILURE).first()).toBeVisible();
} finally {
await seeded.cleanup();
}
});
});
@@ -1,17 +1,24 @@
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
openMissingProjectNewWorkspaceComposer,
openNewWorkspaceComposer,
} from "./helpers/new-workspace";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
openNewWorkspaceProjectPickerWithShortcut,
} from "../support/helpers/new-workspace";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import {
clickArchiveWorkspaceMenuItem,
expectWorkspaceAbsentFromSidebar,
} from "../support/helpers/sidebar";
import { 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
@@ -21,7 +28,7 @@ import { waitForSidebarHydration } from "./helpers/workspace-ui";
// screen, and non-git projects never offer the worktree Isolation control.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`);
return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
}
test.describe("New workspace entry points", () => {
@@ -106,6 +113,31 @@ test.describe("New workspace entry points", () => {
}
});
test("a stale project route keeps the New Workspace controls enabled", async ({ page }) => {
await gotoAppShell(page);
await openMissingProjectNewWorkspaceComposer(page, {
serverId: getServerId(),
projectId: "missing-project",
sourceDirectory: "/tmp/missing-project",
});
await expectNewWorkspaceControlsEnabled(page);
});
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await openNewWorkspaceProjectPickerWithShortcut(page);
} finally {
await seeded.cleanup();
}
});
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
page,
}) => {
@@ -164,13 +196,13 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
// Project A's row icon opens New Workspace with A preselected.
await openNewWorkspaceComposer(page, {
projectKey: projectA.projectId,
projectKey: projectA.projectKey,
projectDisplayName: projectA.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
@@ -179,7 +211,9 @@ test.describe("New workspace entry points", () => {
// manualProjectKey is what the reused 'new' screen must reset when the next
// route-driven navigation targets a different project.
await page.getByTestId("new-workspace-project-picker-trigger").click();
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
const optionC = page.getByTestId(
`new-workspace-project-picker-option-${projectEquivalenceViewKey(projectC.projectKey)}`,
);
await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click();
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
@@ -188,7 +222,7 @@ test.describe("New workspace entry points", () => {
// because the stale manual choice (C) was reset on the route change. If the
// reset were missing, the trigger would still read C.
await openNewWorkspaceComposer(page, {
projectKey: projectB.projectId,
projectKey: projectB.projectKey,
projectDisplayName: projectB.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
@@ -211,8 +245,8 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
@@ -221,7 +255,7 @@ test.describe("New workspace entry points", () => {
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const nonGitOption = page.getByTestId(
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
`new-workspace-project-picker-option-${projectEquivalenceViewKey(nonGitProject.projectKey)}`,
);
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
await nonGitOption.click();
@@ -234,7 +268,7 @@ test.describe("New workspace entry points", () => {
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${gitProject.projectId}`,
`new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`,
);
await expect(gitOption).toBeVisible({ timeout: 30_000 });
await gitOption.click();
@@ -0,0 +1,199 @@
import { expect, test as base } from "../support/fixtures";
import { openProjectDirectoryWithHosts } from "../support/helpers/project-grouping";
import { projectPlacementViewKey } from "../support/helpers/project-view-key";
import {
type IsolatedHostDaemon,
startIsolatedHostDaemon,
} from "../support/helpers/isolated-host-daemon";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceHost,
selectNewWorkspaceProject,
} from "../support/helpers/new-workspace";
import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { createTempGitRepo } from "../support/helpers/workspace";
const PRIMARY_HOST_LABEL = "Primary host";
const SECONDARY_HOST_LABEL = "Secondary host";
const SHARED_PROJECT_NAME = "Paseo";
const SHARED_REMOTE_URL = "https://github.com/getpaseo/paseo.git";
interface CreatedProject {
projectId: string;
projectKey: string;
workspaceId: string;
}
interface CrossHostProjectScenario {
contextWorkspaceId: string;
selectedProjectViewKey: string;
sharedProjectKey: string;
secondaryHost: {
serverId: string;
label: string;
port: number;
};
}
async function createProject(
client: SeedDaemonClient,
input: { projectPath: string; projectName: string; workspaceName: string },
): Promise<CreatedProject> {
const created = await client.createWorkspace({
source: { kind: "directory", path: input.projectPath },
title: input.workspaceName,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project ${input.projectName}`);
}
await client.renameProject(created.workspace.projectId, input.projectName);
const listed = await client.listProjects();
const project = listed.projects.find(
(candidate) => candidate.projectId === created.workspace?.projectId,
);
if (!project?.projectKey) {
throw new Error(`Created project ${created.workspace.projectId} has no project key`);
}
return {
projectId: created.workspace.projectId,
projectKey: project.projectKey,
workspaceId: created.workspace.id,
};
}
async function openNewWorkspaceFromContextProject(
page: Parameters<typeof openProjectDirectoryWithHosts>[0],
scenario: CrossHostProjectScenario,
): Promise<void> {
await openProjectDirectoryWithHosts(page, {
hosts: [scenario.secondaryHost],
primaryLabel: PRIMARY_HOST_LABEL,
});
await expect(
page.getByTestId(`sidebar-project-row-${scenario.selectedProjectViewKey}`),
).toBeVisible();
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: scenario.contextWorkspaceId,
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, "Context project");
}
const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon(
"new-workspace-project-preservation-secondary",
);
const contextRepo = await createTempGitRepo("new-workspace-context-");
const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryDuplicateRepo = await createTempGitRepo("new-workspace-paseo-duplicate-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryFallbackRepo = await createTempGitRepo("new-workspace-fallback-");
const secondarySharedRepo = await createTempGitRepo("new-workspace-paseo-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryDaemon.port });
const primaryProjects: CreatedProject[] = [];
const secondaryProjects: CreatedProject[] = [];
try {
const contextProject = await createProject(primaryClient, {
projectPath: contextRepo.path,
projectName: "Context project",
workspaceName: "Context workspace",
});
primaryProjects.push(contextProject);
const primarySharedProject = await createProject(primaryClient, {
projectPath: primarySharedRepo.path,
projectName: SHARED_PROJECT_NAME,
workspaceName: "Primary Paseo workspace",
});
primaryProjects.push(primarySharedProject);
const primaryDuplicateProject = await createProject(primaryClient, {
projectPath: primaryDuplicateRepo.path,
projectName: "Paseo duplicate",
workspaceName: "Duplicate Paseo workspace",
});
primaryProjects.push(primaryDuplicateProject);
secondaryProjects.push(
await createProject(secondaryClient, {
projectPath: secondaryFallbackRepo.path,
projectName: "Aardvark project",
workspaceName: "Secondary fallback workspace",
}),
);
const secondarySharedProject = await createProject(secondaryClient, {
projectPath: secondarySharedRepo.path,
projectName: SHARED_PROJECT_NAME,
workspaceName: "Secondary Paseo workspace",
});
secondaryProjects.push(secondarySharedProject);
if (
primarySharedProject.projectKey !== primaryDuplicateProject.projectKey ||
primarySharedProject.projectKey !== secondarySharedProject.projectKey
) {
throw new Error("Expected the Paseo clones to have the same equivalence key");
}
await provide({
contextWorkspaceId: contextProject.workspaceId,
selectedProjectViewKey: projectPlacementViewKey(
getServerId(),
primarySharedProject.projectId,
),
sharedProjectKey: primarySharedProject.projectKey,
secondaryHost: {
serverId: secondaryDaemon.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryDaemon.port,
},
});
} finally {
for (const project of primaryProjects) {
await primaryClient.removeProject(project.projectId).catch(() => undefined);
}
for (const project of secondaryProjects) {
await secondaryClient.removeProject(project.projectId).catch(() => undefined);
}
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await contextRepo.cleanup().catch(() => undefined);
await primarySharedRepo.cleanup().catch(() => undefined);
await primaryDuplicateRepo.cleanup().catch(() => undefined);
await secondaryFallbackRepo.cleanup().catch(() => undefined);
await secondarySharedRepo.cleanup().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
}
},
});
test.describe("New workspace host project preservation", () => {
test.describe.configure({ timeout: 180_000 });
test("keeps the equivalent project selected when one host has another clone", async ({
page,
crossHostProject,
}) => {
await openNewWorkspaceFromContextProject(page, crossHostProject);
await selectNewWorkspaceProject(page, {
projectKey: crossHostProject.sharedProjectKey,
projectViewKey: crossHostProject.selectedProjectViewKey,
projectDisplayName: SHARED_PROJECT_NAME,
});
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
});
});
@@ -1,5 +1,5 @@
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
@@ -10,11 +10,11 @@ import {
openProjectViaDaemon,
openStartingRefPicker,
selectBranchInPicker,
} from "./helpers/new-workspace";
import { expectNoTruncation } from "./helpers/no-truncation";
import { createTempGitRepo } from "./helpers/workspace";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
} from "../support/helpers/new-workspace";
import { expectNoTruncation } from "../support/helpers/no-truncation";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Regression for "the local / worktree selection in the new workspace is not
// remembered." The isolation choice persists in the create-form preferences
@@ -0,0 +1,149 @@
import { expect, test, type Page } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
// The draft mode control in New Workspace only mutates local form state; it never sends
// set_agent_mode_request. So the only source of such a request while the New Workspace
// composer is focused is a *live* agent's mode control. Recording those, keyed by agentId,
// gives a direct signal that Shift+Tab leaked into a backgrounded agent.
async function recordSetAgentModeRequests(page: Page): Promise<{
requestsForAgent(agentId: string): Array<{ agentId: string; modeId: string }>;
}> {
const seen: Array<{ agentId: string; modeId: string }> = [];
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "set_agent_mode_request") {
const agentId = typeof sessionMessage.agentId === "string" ? sessionMessage.agentId : "";
const modeId = typeof sessionMessage.modeId === "string" ? sessionMessage.modeId : "";
seen.push({ agentId, modeId });
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return {
requestsForAgent: (agentId: string) => seen.filter((request) => request.agentId === agentId),
};
}
async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise<void> {
await page.addInitScript(
({ preferencesKey, serverId: seededServerId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId: seededServerId,
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4-mini",
mode: "auto",
thinkingByModel: { "gpt-5.4-mini": "low" },
},
mock: { model: "ten-second-stream" },
},
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
);
}
// Focus the New Workspace composer and cycle the execution mode with the keyboard.
// Kept out of the test body so the test reads as intent rather than key mechanics.
async function cycleNewWorkspaceMode(page: Page, presses: number): Promise<void> {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.click();
for (let i = 0; i < presses; i++) {
await page.keyboard.press("Shift+Tab");
}
}
test.describe("New Workspace mode cycle safety", () => {
test.describe.configure({ timeout: 240_000 });
// Regression guard for the P1 safety bug: cycling the execution mode with Shift+Tab in
// the New Workspace composer must never reach a backgrounded, still-mounted agent's mode
// control and silently change that (possibly running) agent's mode — e.g. into a
// permissive/bypass mode. See use-keyboard-action-handler.ts.
test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" });
await seedCodexDefaultPreferences(page, serverId);
const modeRequests = await recordSetAgentModeRequests(page);
try {
const agent = await seeded.client.createAgent({
provider: "codex",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "mode cycle safety e2e",
modeId: "auto",
model: "gpt-5.4-mini",
});
// Mount the live agent tab: its mode control registers a mode-cycle keyboard handler.
await openAgentRoute(page, { workspaceId: seeded.workspaceId, agentId: agent.id });
await expect(
page.getByRole("button", { name: "Select agent mode (Default permissions)" }),
).toBeVisible({ timeout: 30_000 });
// Move to the New Workspace composer. The agent tab stays mounted in the background,
// so its handler is still registered when we cycle here.
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await cycleNewWorkspaceMode(page, 6);
// fetchAgents is a real daemon round-trip; once it resolves, any mode change the
// presses would have triggered has already landed. Assert the running agent is
// untouched — both its committed mode and on the wire — with no fixed sleep.
const agents = await seeded.client.fetchAgents();
const backgroundAgent = agents.entries.find((entry) => entry.agent.id === agent.id)?.agent;
expect(backgroundAgent?.currentModeId).toBe("auto");
expect(modeRequests.requestsForAgent(agent.id)).toEqual([]);
} finally {
await seeded.cleanup();
}
});
});

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