Compare commits

...
5 Commits
Author SHA1 Message Date
b12daabe35 perf(app): row-grained replica cache persistence (#3755)
* feat(hub): guide starter setup (#3651)

* feat(hub): guide setup with provider IDs

* fix(hub): preserve login setup state

* feat(hub): select starter agent runtime

Use the connected daemon's provider snapshot so generated Hub bundles only
contain complete provider, model, and mode combinations.

* fix(hub): stage starter runtime choices

Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime.

* fix(hub): scope runtime choices to workspace

Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.

* fix(server): stop short start gates from undercutting provider startup budgets (#3621)

* fix(server): stop short start gates from undercutting provider startup budgets

Agent creation still failed on contended cold starts after #3578, because two
shorter gates on the same path fire before the event-stream readiness budget can
apply. A traced creation reached session.create twelve seconds in, past the 10s
session.create gate, and the agent was then killed by a bare "timeout" at fifteen
seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running.

A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the
outer gate always wins and the inner budget is unreachable.

Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since
creating the first session for a directory is part of OpenCode coming up, and
derive its error message from the constant instead of hardcoding "10s". Raise
AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay
above the slowest provider startup budget; it stays a literal because that module
is provider-agnostic.

Related to #3571.

* test(server): drive the run start budget through a real AgentManager

The run start boundary tests replaced AgentManager.waitForAgentRunStart with a
bare timer, so they never exercised the production run-state transition or the
agent_state subscription the real wait depends on. That path could regress while
the tests stayed green.

Build the scenario on a real AgentManager with a provider session that holds its
turn start open for a configurable span, so pendingRun.started, the lifecycle
change to "running", and the agent_state notification are what the wait actually
observes. Budgets stay independent literals and the settled-before/after
assertions are unchanged.

* fix(server): describe agent run start timeouts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* fix(relay): validate handshake keys (#3669)

* fix(files): keep large source previews responsive (#3665)

Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.

* fix(hub): make guided setup choices actionable (#3677)

Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.

* Keep released agent SDK versions reproducible (#3678)

Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile.

* feat(app): refine agent profile creation (#3680)

Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers.

* fix(app): restore native composer auto-growth (#3681)

Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it.

* docs(hub): document zero-friction onboarding (#3657)

Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults.

* Surface workspace changes from agent panes (#3682)

* feat(app): refine workspace pane actions

Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant.

* feat(app): improve access to workspace changes

* Expand workspace panel actions and keyboard navigation (#3685)

* fix(app): keep file navigation in place

Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.

* feat(app): expand workspace panel actions

Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.

* Restore compact and stable chat timelines (#3716)

Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position.

* perf(app): defer replica persistence until user inactivity

Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal.

* Give every pane a browser-style New tab (#3715)

* feat(app): replace empty panes with New tabs

Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage.

* test(app): count content tabs in subagent integration

* fix(app): preserve workspace shortcut ownership

* fix(app): keep compact model options scrollable

The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms.

* fix(app): order new tab launcher actions by workflow

* fix(app): show the New agent shortcut

The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping.

* fix(app): prioritize side panel launcher actions

Lead with supporting workspace tools when the launcher is in the Side panel, while ordinary panes continue to lead with agent creation.

* fix(app): align terminal profiles header

* docs: add 0.5.0-beta.5 changelog

* test(app): align new tab launcher expectations

* chore(release): cut 0.5.0-beta.5

* fix: update lockfile signatures and Nix hash [skip ci]

* fix(app): make compact git actions menu-only

Keep compact toolbars narrow and require users to choose potentially consequential Git and workspace actions from the menu.

* fix(app): keep pinned project actions reachable (#3722)

* fix(app): keep pinned project actions reachable

Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available.

Co-authored-by: Agent Op <agentop718@gmail.com>

* test(app): verify pinned project workspace context

---------

Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* fix(app): stop a string SVG transform from crashing the app (#3711)

* fix(app): stop a string SVG transform from crashing the app

A root <Svg> is a real React Native view, so react-native-svg rewrites a
*string* `transform` into RN style syntax in `extractTransformSvgView`,
using a PEG parser whose grammar only accepts SVG transform functions.
Upstream lets that parser's SyntaxError escape, so any string it does not
understand takes the whole app down during render:

  SyntaxError: Expected transform functions but "n" found.
  "found": "n", "location": { "start": { "offset": 0 } }

`none` reproduces that message character for character; so does any other
CSS value (`rotate(-90deg)`, `translate(10px, 0)`). Every sibling
extractor in that file already catches the same error and degrades, so
the asymmetry is the bug: a string that is merely ignored on a <Path>
crashes on an <Svg>.

We feed that path untrusted input. The daemon scans a project for
favicon.svg / icon.svg / logo.svg (packages/server/src/utils/project-icon.ts)
and ships it to the client, where ProjectIconImage hands it to SvgCss.
Running the library's own XML parser over `<svg style="transform:none">`
yields exactly `transform: "none"` on the root, which is why the crash
starts once a host connects and the project icons render, with no
terminal involved.

Note this is *not* reachable from an RN-style array transform: the
extractor returns arrays untouched and only parses strings, so
`style={{ transform: [{ rotate: "-90deg" }] }}` (the context window meter
and the checks ring) was never a factor.

Patch the three builds a consumer can resolve - Metro takes the package's
`react-native` field (src/*.ts), Node and Vitest take main/module - and
register react-native-svg with the postinstall patch runner so the patch
actually applies. The new test pins all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDwfsMWoSFfPm2cFXe5noQ

* test(app): cover parsed root SVG transforms

Exercise the dependency patch with root props produced by the library's XML and CSS parsers, including embedded style rules that can arrive through project icons. Keep dependency-patch documentation focused on the reusable install and resolution constraints.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* fix(pairing): warn that pairing links grant daemon access (#3734)

A pairing offer remains valid across daemon restarts, so remove the incorrect rotation guidance and warn users where the durable credential is displayed.

* fix(app): preserve timeline retry backoff (#3736)

Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action.

* Open the tab chooser beside the pointer (#3735)

* feat(app): open the tab chooser beside the pointer

Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift.

* test(app): follow the anchored tab chooser

The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs.

* fix(app): restore composer height lifecycle (#3740)

Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms.

* fix(server): stop quota fetcher from writing provider credentials (#3442)

* fix(server): recover already-settled interrupts (#3742)

* fix(server): recover already-settled interrupts

Provider state can drift from Paseo’s tracked run. Treat an authoritative already-idle interrupt as settled and retire the captured run token so retrying can start new work without weakening ambiguous-cancellation protection.

* fix(codex): fence interruption during turn startup

Reserve foreground-start ownership before asynchronous provider setup. An interrupt can now cancel preflight before turn/start or wait for an issued start to become interruptible, preventing acknowledged cancellation from leaving unmanaged work.

* docs(changelog): prepare 0.5.0 stable release

* chore(release): cut 0.5.0

* fix: update lockfile signatures and Nix hash [skip ci]

* fix(app): stabilize native composer resizing

JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web.

* docs(changelog): prepare 0.5.1 stable release

* chore(release): cut 0.5.1

* fix: update lockfile signatures and Nix hash [skip ci]

* perf(app): move replica cache validation to restore

Add an observational live measurement harness for the row-store migration baseline. Trust already-shaped cache values on persist while retaining full schema validation at the restore boundary.

* feat(app): add replica cache row store

Ship the platform storage engines behind a dark interface so the cache migration can wire them separately. Both engines share a contract, including atomic writes and destructive schema-version resets.

* feat(app): switch replica cache to row storage

Persist only changed replica entities so steady-state writes scale with the update rather than the complete cached world. Restore remains an all-or-nothing validated boundary, with whole-host byte-budget eviction.

* fix(app): harden replica row persistence

Serialize pending batches at write execution time so retries cannot overwrite newer rows or resurrect removed hosts. Reject oversized hosts before storage writes and preserve unchanged directory identities so routine reconciliation stays row-grained.

---------

Co-authored-by: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com>
Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com>
Co-authored-by: Yuri Gui <yugui923@users.noreply.github.com>
Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Dan Berindei <dan.berindei@gmail.com>
2026-08-24 20:45:49 +02:00
Anton LazarevandMohamed Boudra 43d27328e4 Fastlane metadata format for F-Droid releases (#2501)
* add f-droid metadata support

* share version code calculation with website

* preserve blockquoted sections in changelogs

* refactor(android): simplify stable F-Droid metadata

Generate store changelogs only for stable releases and keep native version-code ownership in the existing release module.

* test(android): run F-Droid changelog suite in CI

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-22 12:43:20 +02:00
nllptrxandMohamed Boudra b79b4bd5fc Feat/forge check semantics and followup fixes (#2337)
* feat(protocol): add forge check presentation metadata

Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible.

* fix(gitea): model Actions jobs as tasks

Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata.

* feat(app): distinguish forge check presentation states

* fix(gitlab): preserve pipeline job semantics

* refactor(app): centralize forge check presentation policy

Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces.

* refactor: consolidate forge check handling

* feat(forge): auto-attach pasted forge references

* docs(compat): correct forge rollout metadata

* refactor(forge): model check nuances as traits

* fix(forge): harden checks from branch review

Structural Gitea Actions shadow dedup by run id (verified upstream),
rerun grouping without workflow_id, shared trait/status constants and
Gitea state mapping in the protocol package, all cloud host aliases in
pasted-link matching, logged glab job-detail failures, concurrent
approvals/pipeline fetch, checks aggregate from the same pipeline as
the job list, and removal of the unread check-level rawStatus. The
released pipeline-level rawStatus is staged for removal under
COMPAT(pipelineRawStatus).

* fix(gitea): detect fork-PR Actions approval hold as action_required

gitea.com represents a fork PR awaiting Actions approval as a run in
status "waiting" with no tasks and pending shadow commit statuses; it
does not expose need_approval. The previous mapping only set
action_required from a task with blocked+need_approval, so the gate
never surfaced (0 tasks -> early return; need_approval absent). Detect
the hold from /actions/runs: a waiting/blocked run whose PR is
cross-repo (head.repo.id != base.repo.id), or one that does expose
need_approval (self-hosted). Tag the run's pending shadow statuses
action_required. Runs are only fetched when a pending shadow has no
covering task, keeping the common path at two requests. Verified live
against gitea.com fork PR (run 715170, waiting, cross-repo).

* fix(gitea): resolve cross-fork PRs by head sha

The current-PR matcher gated candidates on the checkout owner
(giteaHeadOwner === expectedHeadOwner), which excludes every fork PR:
its head carries the fork owner while the checkout is the base repo, so
a fork PR - the only shape that can be approval-held on Gitea - never
resolved and its checks never showed in-app. GitLab has no such gate
(it resolves fork MRs from the target project by source branch). Accept
a recent PR whose head sha equals the workspace head sha regardless of
owner; the sha is an unambiguous identity, so the anti-collision owner
gate still applies when no sha match is available. Verified live against
gitea.com fork PR #6 (resolves + renders action_required).

* fix: resolve forge rebase drift

* fix: type forge auto-attach boundary

* fix(i18n): add Korean check status copy

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-22 12:42:54 +02:00
dac6cd141a Stream assistant text at a steady rate instead of in arrival lumps (#3612)
* Stream assistant text at a steady rate instead of in arrival lumps

Assistant text arrived in lumps and was painted that way. The daemon
coalesces one message per 60ms window, and that window carries however
many characters the model produced in it, which swings by an order of
magnitude within a turn. Painting each delta as it landed made the size
of those lumps visible.

Three changes:

- The agent stream coalescer is now leading + trailing. The first delta
  after an idle window flushes synchronously instead of waiting a full
  window, matching the invariant TerminalOutputCoalescer already
  documents. The window stays at 60ms; shrinking it would raise message
  rate on a loop that already contends with terminal frames and relay
  encryption, without making the stream smoother.

- The client reducer queue commits on a frame instead of an arbitrary
  48ms timer, with the timer kept as a ceiling so a hidden tab still
  advances.

- Assistant and reasoning text are revealed at a rate derived from the
  backlog rather than from arrival timing. Arrival changes the target,
  not the rate. The store keeps the full text and only the rendered
  slice is paced, so copy, selection, the chat outline, and scroll
  geometry all stay consistent with what is on screen.

The leading flush means a burst's first chunk lands as its own timeline
row. The projection already merges contiguous same-turn rows and clients
read the projected timeline, so history is unchanged; the server tests
that assert on raw rows were updated for the extra row.

Measured on Expo web against a local dev daemon. Mock bursty-stream
provider, 6s samples, paint-on-arrival vs paced: frames that advanced
the text 55 -> 244, chars-per-frame CV 2.66 -> 1.44, gap p50 100ms ->
17ms. Real Claude Haiku agent over 52s: 84% of frames advance the text,
CV 1.29, gap p50 and p95 both 17ms (one frame at 60Hz).

Adds a bursty-stream mock model with seeded burst sizes, a gated browser
perf spec, and docs/agent-stream-performance.md.

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

* Address review: safe reveal boundaries and a testable seam

Two findings from the PR review.

Unicode boundaries. Slicing the revealed text at an arbitrary index could
strand a lone surrogate, which paints as a replacement glyph, or split an
emoji ZWJ sequence so a family briefly rendered as its members. Both were
visible for a frame or two mid-stream — the exact flicker the paced reveal
exists to remove. The rendered cut is now pulled back off surrogate pairs,
combining marks, variation selectors, skin tone modifiers, and ZWJ joins.
The reveal counter stays monotonic and only the painted boundary moves, so
a long cluster cannot stall the reveal.

Test placement. The hook test used JSDOM, @testing-library renderHook, and
monkey-patched requestAnimationFrame, all of which docs/testing.md rules
out; it prescribes fixing the missing seam instead. The reveal policy is
now a pure state machine in text-reveal.ts (begin/retarget/advance/
complete/visible) covered by plain unit tests with no environment, and the
hook is reduced to the frame-clock binding. Rendered behavior is left to
the browser spec, which no longer carries frame sampling and statistics in
its body — those moved to e2e/support/helpers/stream-smoothness.ts.

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

* Measure total painted text, not the tail message

The smoothness sampler read the length of the last assistant-message
element. A real turn emits many assistant messages, so that element keeps
changing identity and its length is not monotonic — handovers read as
resets, and a stream that was plainly moving on screen reported almost no
growth at all. Sampling the total across every assistant message fixes it.

This invalidated the numbers previously recorded for the real-agent run,
so the doc table is replaced with a fresh A/B taken with the corrected
sampler against a Claude Haiku agent: frames that advanced the text 6% ->
87%, chars-per-frame CV 4.11 -> 1.86, gap p50 317ms -> 17ms, p95 383ms ->
17ms. The paced column also reported zero Unicode defects across a stream
containing emoji, which exercises the reveal boundary clamp end to end.

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

* perf(agent-stream): harden paced text reveal

Use standards-compliant grapheme segmentation when available and preserve full arrivals on older runtimes. Cap reveal commits at 60Hz and measure the real browser rendering path so high-refresh displays stay smooth without rendering at hardware frame rate.

* Hold back trailing fragments split by arrival boundaries

The reveal boundary is not the only place a grapheme gets cut. The
daemon's coalescing window ends a delta wherever the window closes, so a
caught-up reveal can be handed half a cluster and paint it. Measured on a
mixed-script Haiku stream: five frames in ~350 ending in a single regional
indicator, i.e. half a flag on screen for a frame.

While the turn is streaming, a trailing fragment that cannot stand alone —
a dangling high surrogate, a trailing joiner, an odd regional indicator —
now waits for the rest of its cluster. Once the turn ends everything
received is painted, because nothing more is coming. Complements the
grapheme-boundary clamp, which handles the reveal cut; this handles the
arrival cut.

The same stream now measures one such frame in ~400 rather than five. The
remainder is a flush boundary that coincides with the end of an assistant
message, putting the two halves in different messages, which the client
cannot rejoin — that one belongs in the coalescer.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-22 12:38:49 +02:00
Mohamed Boudra c3ae54a1a2 fix(rewind): replace long timelines without replay (#3642)
* fix(rewind): refresh canonical timeline after rewind

A rewind replaces the daemon timeline and epoch, but replaying the rebuilt history made long conversations visibly reload from the beginning. Capable clients now refresh the existing bounded tail path after a replacement signal while legacy clients retain replay compatibility.

* test(rewind): cover coherent timeline replacement

Hold rewind completion in the browser regression so it can verify the existing pending state and detect any reconstructed timeline rows before the normal tail refresh is allowed to finish.
2026-08-21 19:09:56 +02:00
347 changed files with 16334 additions and 4552 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
filters: .github/ci-paths.yml
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
format:
name: format
+59 -76
View File
@@ -1,98 +1,81 @@
# Changelog
## 0.5.0-beta.4 - 2026-08-21
## 0.5.1 - 2026-08-23
### Fixed
- Fixed multiline composers stopping resizing after workspace and agent transitions on iOS and Android ([#3740](https://github.com/getpaseo/paseo/pull/3740))
## 0.5.0 - 2026-08-23
### Added
- Added experimental trusted local plugins ([#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
- Added plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan))
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
- Added a user-directed Side panel with independent tabs ([#3287](https://github.com/getpaseo/paseo/pull/3287), [#3605](https://github.com/getpaseo/paseo/pull/3605))
- Added browser-style New tabs with an anchored pane-local chooser ([#3715](https://github.com/getpaseo/paseo/pull/3715), [#3735](https://github.com/getpaseo/paseo/pull/3735))
- Added active-turn steering for Claude, Codex, and OpenCode ([#3394](https://github.com/getpaseo/paseo/pull/3394), [#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
- Added workspace labels for sidebar organization and filtering ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
- Added live workspace change counts above active agent composers ([#3682](https://github.com/getpaseo/paseo/pull/3682))
- Added workspace panel, tab, and pane actions to the Command Center ([#3685](https://github.com/getpaseo/paseo/pull/3685))
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
- Added `paseo reload` to apply runtime-safe `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
- Added project filtering to the sidebar display menu ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter))
- Added active-turn steering for OpenCode ([#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
- Added live sheets for summarized tool-call groups on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
- Added one-click agent profile creation from the model chooser ([#3533](https://github.com/getpaseo/paseo/pull/3533))
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531), [#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
- Added Nix syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy))
- Added Svelte syntax highlighting ([#3487](https://github.com/getpaseo/paseo/pull/3487), [#3534](https://github.com/getpaseo/paseo/pull/3534) by [@fiorelorenzo](https://github.com/fiorelorenzo))
- Added guided Hub setup that connects a daemon and deploys a compatible starter workflow ([#3651](https://github.com/getpaseo/paseo/pull/3651), [#3657](https://github.com/getpaseo/paseo/pull/3657), [#3677](https://github.com/getpaseo/paseo/pull/3677))
### Improved
- Changed the Side panel into a user-directed surface with independent tabs and configurable supporting-tab routing ([#3605](https://github.com/getpaseo/paseo/pull/3605))
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
- Reduced Android workspace-switch stalls after using several long chats ([#3610](https://github.com/getpaseo/paseo/pull/3610))
- Restored cached projects, workspaces, agents, and timelines immediately while hosts reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
- Reduced workspace-switch stalls on desktop and Android with long chats ([#3447](https://github.com/getpaseo/paseo/pull/3447), [#3610](https://github.com/getpaseo/paseo/pull/3610))
- Kept composer typing within the frame budget on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
- Kept large diffs responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
- Kept large read-only source previews responsive with bounded rendering ([#3665](https://github.com/getpaseo/paseo/pull/3665))
- Removed complete-transcript disk rewrites from long-running agent timelines ([#3647](https://github.com/getpaseo/paseo/pull/3647))
- Added outcome summaries and failure-first grouping to pull request checks ([#3483](https://github.com/getpaseo/paseo/pull/3483))
- Moved subagent and task trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
- Opened summarized tool-call groups in live sheets on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
- Updated Pi usage and context meters during active turns ([#3532](https://github.com/getpaseo/paseo/pull/3532))
- Changed Archive finished to archive every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
- Changed automatic Setup tabs to appear only after workspace setup fails ([#3634](https://github.com/getpaseo/paseo/pull/3634))
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
- Added password-equivalent warnings to pairing links in the desktop app and CLI ([#3734](https://github.com/getpaseo/paseo/pull/3734))
- Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz))
### Fixed
- Fixed modified tab indicators overlapping truncated tab titles
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
- Fixed OpenCode turns failing when plugin-heavy startup takes more than ten seconds ([#3578](https://github.com/getpaseo/paseo/pull/3578) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
- Fixed Android Studio showing a generic terminal icon in the desktop open-target menu ([#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
## 0.5.0-beta.3 - 2026-08-20
### Fixed
- Fixed the composer retaining sent text on iOS and Android ([#3564](https://github.com/getpaseo/paseo/pull/3564))
- Fixed pull request checkout in fork workflows where the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
## 0.5.0-beta.2 - 2026-08-19
### Added
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531) by [@caikovsky](https://github.com/caikovsky))
### Fixed
- Fixed `npm install @getpaseo/cli@beta` failing with a 404 because the plugin SDK it depends on was never published
- Renamed the plugin SDK package to `@getpaseo/plugin`, with `@paseo/plugin` imports still resolving for existing plugins
- Fixed the old docked file explorer sidebar staying open next to the explorer pane after upgrading to 0.5.0-beta.1, with no way to dismiss it on Windows and Linux
- Fixed new agents, terminals, and browser tabs opening in a background pane instead of the focused one
## 0.5.0-beta.1 - 2026-08-18
### Added
- Add experimental support for plugins ([See docs](/docs/plugins), [#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
- Added workspace labels to organize the sidebar ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
- Added steering for Codex and Claude — send into a running turn instead of interrupting it ([#3394](https://github.com/getpaseo/paseo/pull/3394))
- Reworked the explorer into a persistent pane with its own tabs, so files, changes, and the pull request stay open next to the chat ([#3287](https://github.com/getpaseo/paseo/pull/3287))
- Changed Cmd/Ctrl+T to open a menu so you can choose what the new tab opens
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
- Added `paseo reload` to apply `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
- Added Nix and Svelte syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy), [#3487](https://github.com/getpaseo/paseo/pull/3487) by [@fiorelorenzo](https://github.com/fiorelorenzo))
### Improved
- Improved checks summary to the pull request pane, grouped by status with failures first ([#3483](https://github.com/getpaseo/paseo/pull/3483))
- Projects, workspaces, and agents show up instantly when the app opens ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
- Offline hosts keep showing their projects and workspaces while they reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259))
- Large diffs stay responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
- Switching workspaces with keyboard shortcuts no longer lags ([#3447](https://github.com/getpaseo/paseo/pull/3447))
- Typing in the composer no longer lags on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
- Moved the subagents and tasks trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
- Archive finished now archives every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
- The text size setting now scales the whole interface
### Fixed
- Fixed local plugin install on the packaged desktop app: esbuild can spawn from `app.asar.unpacked`, and the plugin subprocess loads `@paseo/plugin/server` instead of the React client barrel
- Fixed CJK IME composition being cancelled in text fields and the mobile terminal ([#3517](https://github.com/getpaseo/paseo/pull/3517), [#2811](https://github.com/getpaseo/paseo/pull/2811) by [@northsea4](https://github.com/northsea4), [#3462](https://github.com/getpaseo/paseo/pull/3462) by [@chulmin-dev](https://github.com/chulmin-dev), [#3343](https://github.com/getpaseo/paseo/pull/3343) by [@jimersylee](https://github.com/jimersylee), [#3391](https://github.com/getpaseo/paseo/pull/3391) by [@ZacharyZcR](https://github.com/ZacharyZcR), [@ljy900825-dev](https://github.com/ljy900825-dev))
- Fixed OpenCode turns failing when its event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
- Fixed CJK IME composition being cancelled in text fields and mobile terminals ([#2811](https://github.com/getpaseo/paseo/pull/2811), [#3343](https://github.com/getpaseo/paseo/pull/3343), [#3391](https://github.com/getpaseo/paseo/pull/3391), [#3462](https://github.com/getpaseo/paseo/pull/3462), [#3517](https://github.com/getpaseo/paseo/pull/3517) by [@northsea4](https://github.com/northsea4), [@jimersylee](https://github.com/jimersylee), [@ZacharyZcR](https://github.com/ZacharyZcR), [@chulmin-dev](https://github.com/chulmin-dev))
- Fixed OpenCode turns failing when their event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
- Fixed OpenCode agents timing out during slow or plugin-heavy startup ([#3578](https://github.com/getpaseo/paseo/pull/3578), [#3621](https://github.com/getpaseo/paseo/pull/3621) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
- Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
- Fixed resumed subagents splitting into separate rows
- Fixed a detected pull request revealing the explorer pane and stealing focus, which then made new agents, terminals, and browser tabs open inside the explorer pane instead of the main one
- Fixed workspace pull request status being lost on reload
- Fixed hidden terminal tabs stopping output in the focused workspace
- Fixed mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564))
- Fixed multiline composers retaining stale heights across workspace and draft lifecycles ([#3681](https://github.com/getpaseo/paseo/pull/3681), [#3740](https://github.com/getpaseo/paseo/pull/3740))
- Fixed pull request checkout when the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
- Fixed provider usage checks overwriting Claude or Codex credential files ([#3442](https://github.com/getpaseo/paseo/pull/3442) by [@danberindei](https://github.com/danberindei))
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
- Fixed agents remaining impossible to stop after the provider had already settled their turn ([#3742](https://github.com/getpaseo/paseo/pull/3742))
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
- Fixed project icons showing stale images in New Workspace ([#3600](https://github.com/getpaseo/paseo/pull/3600))
- Fixed malformed SVG project icons crashing the iOS app ([#3711](https://github.com/getpaseo/paseo/pull/3711) by [@omercnet](https://github.com/omercnet))
- Fixed pinning a project's only workspace hiding its New workspace action ([#3722](https://github.com/getpaseo/paseo/pull/3722) by [@yugui923](https://github.com/yugui923))
- Fixed workspace layout changes bypassing timeline retry backoff ([#3736](https://github.com/getpaseo/paseo/pull/3736))
- Fixed fresh installs resolving provider SDK versions that were not validated with the release ([#3678](https://github.com/getpaseo/paseo/pull/3678))
## 0.4.0 - 2026-08-13
+41 -40
View File
@@ -21,46 +21,47 @@ This is an npm workspace monorepo:
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
| Doc | What's in it |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [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) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
| Doc | What's in it |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [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) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [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/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
### Writing docs
+10 -1
View File
@@ -35,7 +35,16 @@ be in flight.
### Cancellation
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
Provider interruption is idempotent at the `AgentSession` boundary. It resolves when the prior
foreground turn can no longer run, including when the provider reports that it is already idle. It
rejects only when the provider may still own the turn. Provider adapters translate native errors
into that contract; lifecycle callers do not interpret provider-specific errors.
After an acknowledged interrupt, the manager settles the captured run even when no terminal event
arrives or the run was still waiting for its provider turn id. The captured run token prevents an
older cancellation from settling a newer turn. If interruption is rejected or times out, the agent
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
Accepting new work after an ambiguous interruption would create a split-brain session.
## Relationships
+52
View File
@@ -0,0 +1,52 @@
# Agent stream performance
How assistant text gets from a provider to the screen, and why it is paced on the way. Read this before changing `packages/server/src/server/agent/agent-stream-coalescer.ts`, the reducer queue in `packages/app/src/timeline/session-stream-reducers.ts`, or the reveal in `packages/app/src/hooks/use-revealed-text.ts`.
For terminal output, which is a separate pipeline with separate budgets, see [terminal-performance.md](terminal-performance.md).
## The pipeline
```
provider deltas (every provider streams incrementally)
→ AgentStreamCoalescer (daemon, leading + trailing, ≤1 message per 60ms per agent)
→ recordTimeline: one canonical row per flushed item
→ agent_stream ws message
→ reducer queue (app, one commit per frame) → session store
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → paint
```
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
## Why the reveal is paced
Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged.
So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption.
## Invariants
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
## Measuring
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
| | paint on arrival | paced |
| -------------------------------- | ---------------- | ----- |
| frames that advanced the text | 6% | 87% |
| chars-per-frame CV | 4.11 | 1.86 |
| gap between visible updates, p50 | 317ms | 17ms |
| gap between visible updates, p95 | 383ms | 17ms |
Total characters painted is roughly the same either way — the reveal changes when they land, not how many arrive.
Measure the **total** length across every `assistant-message` element, not the last one. A turn emits many assistant messages, so the tail element keeps changing identity and its length is not monotonic; sampling only the tail reads those handovers as resets and reports almost no growth. `sampleStreamFrames` does this correctly.
+34 -1
View File
@@ -15,7 +15,9 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
## Version codes
`packages/app/app.config.js` derives Android `versionCode` from the package version with:
`packages/app/native-release-version.js` is the single definition of native and F-Droid version-code math. Do not re-derive these numbers anywhere else — a drifted copy produces changelog files that match no published APK, and nothing fails loudly.
The base version code comes from the package version:
```text
major * 1_000_000 + minor * 1_000 + patch
@@ -145,6 +147,37 @@ Keep the excluded npm packages installed. Normal builds use them, while the F-Dr
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
### F-Droid store metadata
F-Droid reads the store listing from `fastlane/metadata/android/<locale>/` **at the repo root**. This location provides the best compatibility with the F-Droid release process.
```text
fastlane/metadata/android/
├── en-US/ (F-Droid fallback locale, mandatory)
│ ├── title.txt (<=50 chars)
│ ├── short_description.txt (<=80 chars)
│ ├── full_description.txt (<=4000 chars, limited HTML)
│ ├── images/
│ │ ├── icon.png (512x512)
│ │ ├── featureGraphic.png (1024x500)
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
│ └── changelogs/ (generated — see below)
├── ja/
└── zh-CN/
```
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
Stable sync fails loudly if `CHANGELOG.md` has no entry for the version being cut. That is intentional — the release checklist requires the entry to be committed first, so an abort here means the checklist was skipped.
Because the generator runs off the version in `package.json`, it must run **before** the tag is created: fdroidserver only reads metadata from the tag it builds, so the file for version N has to exist in the commit N points at.
Beta releases are an explicit no-op: they do not create or rewrite F-Droid changelog files. Stable releases and promotions generate the four ABI entries from their final changelog.
### React version lockstep
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
+9 -6
View File
@@ -104,7 +104,7 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false.
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
- `SessionContext` wraps the daemon client for the active session
@@ -115,12 +115,15 @@ Cross-platform React Native app that connects to one or more daemons.
The replica cache paints stale data immediately while the host connects. Directory cursors are
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
Pending permission requests are not restored from it. The row store is not encrypted, so the cached
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
host snapshots in least-recently-written order; a single oversized host is omitted rather than
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
AsyncStorage, and Android reserves 64 MiB for that database.
separate product/security decision. Agents, workspaces, and projects have individual rows; the
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is
an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use
IndexedDB. Native builds use expo-sqlite.
The three directory entity types have independent monotonic sequences and share one daemon
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
+10
View File
@@ -502,6 +502,16 @@ For tighter loops, you can rebuild a single workspace:
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
- Changed app build dependencies: `npm run build:app-deps`.
## Dependency patches
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
runs when its package is actually present, so add the package to that script's `patchedPackages`
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
patch every build the consumers use: Metro resolves the `react-native` field of a package
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
+33 -3
View File
@@ -26,8 +26,9 @@ For forge `acme`, the expected end state is:
3. **App modules** - a forge splits into a pure logic half and a view half so
logic consumers (URL builders, merge-capability, native checks, and the
Node-based e2e harness) never pull the client rendering stack:
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`,
optional `facts` (schema, merge-capability, native-check fallbacks). No
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`
(source links and pasted issue/change-request reference paths), optional
`facts` (schema, merge-capability, native-check fallbacks). No
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
`packages/app/src/git/forges/index.ts`.
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
@@ -57,8 +58,20 @@ schema-mismatched facts render neutrally instead of failing the whole message
parse. This is the version-skew win: an old client can receive facts from a
newer forge and still show the PR/MR in a neutral state.
Check results keep a stable normalized `status` for aggregation and an optional
open `traits: string[]` for composable forge-neutral refinements such as
`manual`, `action_required`, and `warning`. Consumers ignore unknown traits and
fall back to `status`; `rawStatus` preserves provider-native detail. Do not add
provider vocabulary to the normalized status union. Producers and the
presentation layer share the trait constants in
`packages/protocol/src/check-traits.ts` — use those instead of string literals
so classification and emission cannot drift apart.
Shipped GitHub compatibility stays separate:
- The forge compatibility layer first shipped in `v0.2.0-beta.1`. Its initial
six-month cleanup target is `2027-01-17`, after supported client and daemon
floors reach stable `v0.2.0`.
- `status.github` remains accepted for released peers.
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
into `status.github` for older clients.
@@ -112,7 +125,7 @@ rendering stack:
`acme.ts` exports a `ClientForgeLogicModule`:
- `id`
- optional `urlGrammar`
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment)
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
`acme.view.tsx` exports a `ClientForgeViewModule`:
@@ -181,10 +194,27 @@ are stale, run `npm run build:server`.
real shims tagged with `COMPAT(name)`.
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
top-level brand is Forgejo or Codeberg.
- Gitea's legacy Actions task endpoint cannot filter by commit, may enforce a
page size below the requested limit, and omits rerun-attempt numbers. Walk a
bounded number of pages using `total_count` and filter locally. Preserve
ambiguous same-run tasks: display names are not stable job identities, so
name-based deduplication can hide a real failure.
- A blocked Gitea Actions task is not necessarily manual: only add the
`action_required` trait when the matching run metadata reports
`need_approval`.
- Brand icons are bundled React components, so they cannot come from protocol
manifest data.
- Source URL grammars are app-side because blob/tree path syntax is
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
rather than constructing a wrong URL.
- Pasted issue/change-request URL recognition is also app-side and registry
driven. Put the forge's route infixes in `urlGrammar.referencePaths`; the
neutral extractor matches the pasted host and full repository path against
the current remote before it invokes cwd-routed forge search. Do not add a
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
- GitLab pipeline status constants belong to the GitLab adapter/client module,
not protocol.
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
allowed-failure job carries the `warning` trait, and `canceling` remains active
until GitLab reports a terminal status.
+4
View File
@@ -147,6 +147,10 @@ Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
### Usage fetchers are read-only on credentials
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
---
## ACP Provider Checklist
+1
View File
@@ -436,6 +436,7 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
- The npm `version` lifecycle regenerates F-Droid changelog files from `CHANGELOG.md` for stable releases only (`npm run fdroid:changelogs`) and stages them, so the release tag carries them. Betas are a no-op. A stable run **aborts the release** if `CHANGELOG.md` has no entry for the version being cut — commit the changelog entry first. See [docs/android.md](android.md) for why these files are generated per ABI.
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
+5 -1
View File
@@ -83,7 +83,11 @@ Running and successful setup never seed tabs.
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
to it.
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
supporting opens: they follow the preference and focus an existing Changes tab wherever
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
hides the panel without closing the tab. A different visible Side panel tab switches to
Changes instead.
## The name
+1 -1
View File
@@ -43,6 +43,6 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
## 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.
- 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. See [agent-stream-performance.md](agent-stream-performance.md) for that pipeline's own budgets.
- 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.
+1 -1
View File
@@ -49,7 +49,7 @@ A failed catch-up or subscription reconcile retries on its own, doubling from 1s
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
condition clears.
condition clears. Republishing the same visibility set is a no-op and never bypasses backoff.
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
@@ -0,0 +1,37 @@
Paseo is a client for AI coding agents that run on your own machine. Your dev environment, in your pocket.
Paseo runs a local server called the daemon on your computer. It manages your coding agents and streams their output in real time. This app connects to that daemon, so your code never leaves your machine and there is no cloud service in between.
<b>Supported agents</b>
<ul>
<li>Claude Code</li>
<li>Codex</li>
<li>GitHub Copilot</li>
<li>OpenCode</li>
<li>Pi</li>
</ul>
You bring your own provider credentials and use your existing subscriptions. Paseo adds no markup and no inference costs of its own.
<b>What you can do</b>
<ul>
<li>Start agents, watch them work, and answer their questions from your phone</li>
<li>Run several agents in parallel across projects and git worktrees</li>
<li>Review diffs, browse commit history, and edit files</li>
<li>Work with pull requests from GitHub, GitLab, Gitea, Forgejo, and Codeberg</li>
<li>Open a terminal in any workspace</li>
<li>Dictate prompts or talk through a problem in voice mode</li>
<li>Schedule agents to run on a recurring basis</li>
</ul>
<b>Privacy</b>
No telemetry, no tracking, no account required. The app talks only to daemons you point it at. Remote access goes through an end-to-end encrypted relay that cannot read your traffic, or over your own network or VPN if you prefer.
<b>You need a daemon to connect to</b>
This app is not useful on its own. Install the Paseo daemon on the computer where your code and agent CLIs live, then pair this app with it from Settings. The desktop app bundles the daemon, or you can run it from npm, Docker, or Nix.
Setup instructions: https://paseo.sh
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1 @@
../../../../../../packages/website/public/phone-1.png
@@ -0,0 +1 @@
../../../../../../packages/website/public/phone-2.png
@@ -0,0 +1 @@
../../../../../../packages/website/public/phone-3.png
@@ -0,0 +1 @@
Monitor and control your local AI coding agents from anywhere
@@ -0,0 +1 @@
Paseo
@@ -0,0 +1,37 @@
Paseo は、自分のマシン上で動作する AI コーディングエージェントのクライアントです。あなたの開発環境を、ポケットの中に。
Paseo はデーモンと呼ばれるローカルサーバーをあなたのコンピューター上で実行します。デーモンがコーディングエージェントを管理し、その出力をリアルタイムに配信します。このアプリはそのデーモンに接続するため、コードがマシンの外に出ることはなく、間にクラウドサービスも入りません。
<b>対応エージェント</b>
<ul>
<li>Claude Code</li>
<li>Codex</li>
<li>GitHub Copilot</li>
<li>OpenCode</li>
<li>Pi</li>
</ul>
プロバイダーの認証情報はご自身のものを使用し、既存のサブスクリプションをそのまま活用できます。Paseo が独自の手数料や推論コストを追加することはありません。
<b>できること</b>
<ul>
<li>スマートフォンからエージェントを起動し、作業を確認し、質問に回答する</li>
<li>プロジェクトや git worktree をまたいで複数のエージェントを並列実行する</li>
<li>差分のレビュー、コミット履歴の閲覧、ファイルの編集</li>
<li>GitHub、GitLab、Gitea、Forgejo、Codeberg のプルリクエストを扱う</li>
<li>任意のワークスペースでターミナルを開く</li>
<li>音声でプロンプトを入力したり、音声モードで問題を相談する</li>
<li>エージェントを定期実行するようスケジュールする</li>
</ul>
<b>プライバシー</b>
テレメトリー、トラッキング、アカウント登録は一切ありません。アプリは、あなたが指定したデーモンにのみ通信します。リモートアクセスは通信内容を読み取れないエンドツーエンド暗号化リレーを経由するか、ご自身のネットワークや VPN を利用できます。
<b>接続先のデーモンが必要です</b>
このアプリは単体では機能しません。コードとエージェント CLI があるコンピューターに Paseo デーモンをインストールし、設定画面からこのアプリをペアリングしてください。デスクトップアプリにはデーモンが同梱されています。npm、Docker、Nix から実行することもできます。
セットアップ手順: https://paseo.sh
@@ -0,0 +1 @@
ローカルのAIコーディングエージェントをどこからでも操作
@@ -0,0 +1,37 @@
Paseo 是运行在你自己机器上的 AI 编程 agents 的客户端。把你的开发环境装进口袋。
Paseo 会在你的电脑上运行一个名为 daemon 的本地服务,由它管理编程 agents 并实时推送输出。本应用连接到该 daemon,因此你的代码不会离开你的机器,中间也没有任何云服务。
<b>支持的 agents</b>
<ul>
<li>Claude Code</li>
<li>Codex</li>
<li>GitHub Copilot</li>
<li>OpenCode</li>
<li>Pi</li>
</ul>
你使用自己的提供商凭据和现有订阅。Paseo 不加价,也不产生自己的推理费用。
<b>你可以做什么</b>
<ul>
<li>在手机上启动 agents、查看进展并回答它们的提问</li>
<li>跨项目和 git worktree 并行运行多个 agents</li>
<li>审查差异、浏览提交历史、编辑文件</li>
<li>处理来自 GitHub、GitLab、Gitea、Forgejo 和 Codeberg 的合并请求</li>
<li>在任意工作区打开终端</li>
<li>用语音输入提示,或在语音模式下讨论问题</li>
<li>将 agents 设置为定期运行</li>
</ul>
<b>隐私</b>
没有遥测、没有追踪、无需账号。应用只与你指定的 daemon 通信。远程访问通过无法读取你流量的端到端加密中继,或者你也可以使用自己的网络或 VPN。
<b>你需要一个可连接的 daemon</b>
本应用无法独立使用。请在存放代码和 agent CLI 的电脑上安装 Paseo daemon,然后在设置中将本应用与其配对。桌面应用已内置 daemon,你也可以通过 npm、Docker 或 Nix 运行。
安装说明:https://paseo.sh
@@ -0,0 +1 @@
随时随地监控和控制本地的 AI 编程 agents
+1 -1
View File
@@ -1 +1 @@
sha256-TNwqDZ9gJP613iYmDzVfK5vfye5NBloKN5Jt9t7yhx8=
sha256-13Q44pEU+V2M/wnitVAlBarYxAqWkGkXrE+w5qUCf7o=
+70 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -15070,6 +15070,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/await-lock": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
@@ -21336,6 +21342,20 @@
"node": ">=8.0"
}
},
"node_modules/expo-sqlite": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz",
"integrity": "sha512-tUOKxE9TpfneRG3eOfbNfhN9236SJ7IiUnP8gCqU7umd9DtgDGB/5PhYVVfl+U7KskgolgNoB9v9OZ9iwXN8Eg==",
"license": "MIT",
"dependencies": {
"await-lock": "^2.2.2"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-system-ui": {
"version": "6.0.9",
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz",
@@ -21781,6 +21801,16 @@
"license": "MIT",
"optional": true
},
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-copy": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
@@ -36140,7 +36170,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36200,6 +36230,7 @@
"expo-router": "~6.0.13",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-sqlite": "~16.0.10",
"expo-system-ui": "~6.0.7",
"fast-deep-equal": "^3.1.3",
"htmlparser2": "^12.0.0",
@@ -36241,6 +36272,7 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/markdown-it": "^14.1.2",
"@types/node": "~22.19.0",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/turndown": "^5.0.6",
@@ -36254,6 +36286,7 @@
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"expo-gradle-jvmargs": "^1.1.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^20.0.3",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
@@ -36791,6 +36824,16 @@
"node": ">=18"
}
},
"packages/app/node_modules/@types/node": {
"version": "22.19.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"packages/app/node_modules/@xterm/addon-clipboard": {
"version": "0.3.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.213.tgz",
@@ -37192,12 +37235,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/server": "0.5.0-beta.4",
"@getpaseo/client": "0.5.1",
"@getpaseo/protocol": "0.5.1",
"@getpaseo/server": "0.5.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37444,10 +37487,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"dependencies": {
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/relay": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.1",
"@getpaseo/relay": "0.5.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37458,7 +37501,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37701,7 +37744,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38597,7 +38640,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38626,12 +38669,12 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/client": "0.5.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38640,14 +38683,14 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/client": "0.5.1",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38660,7 +38703,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38878,19 +38921,19 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/highlight": "0.5.0-beta.4",
"@getpaseo/plugin": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/relay": "0.5.0-beta.4",
"@getpaseo/client": "0.5.1",
"@getpaseo/highlight": "0.5.1",
"@getpaseo/plugin": "0.5.1",
"@getpaseo/protocol": "0.5.1",
"@getpaseo/relay": "0.5.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
@@ -39940,7 +39983,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -84,6 +84,8 @@
"android:release": "npm run android:production --workspace=@getpaseo/app",
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
@@ -91,7 +93,7 @@
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version": "npm run version:sync-internal && npm run fdroid:changelogs && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
@@ -3,10 +3,13 @@ import { expect, test, type Page } from "../support/fixtures";
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import {
cancelAgent,
fillComposerDraft,
expectComposerDraft,
expectComposerVisible,
submitMessage,
} from "../support/helpers/composer";
import { expectNearBottom, scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
import { holdRewindCompletion } from "../support/helpers/agent-timeline-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function completeSubmittedTurn(
@@ -42,9 +45,13 @@ async function expectTurnCompletesNormally(
}
test.describe("Agent message rewind", () => {
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
test("rewinds a submitted prompt without replaying history and preserves a human draft", async ({
page,
}) => {
test.setTimeout(90_000);
const prompt = "Restore this submitted prompt after rewind.";
const retainedPrompt = "Keep this completed turn after rewind.";
const rewoundPrompt = "Remove this completed turn after rewind.";
const preservedDraft = "Keep this human draft after rewind.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-roundtrip-",
title: "Message rewind roundtrip",
@@ -52,14 +59,30 @@ test.describe("Agent message rewind", () => {
});
try {
const rewindCompletion = await holdRewindCompletion(page, agent.agentId);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const userMessage = await completeSubmittedTurn(page, agent, prompt);
await completeSubmittedTurn(page, agent, retainedPrompt);
const userMessage = await completeSubmittedTurn(page, agent, rewoundPrompt);
await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
await fillComposerDraft(page, preservedDraft);
rewindCompletion.clearTimelineStreamCount();
await rewindConversation(page, userMessage, prompt);
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
await rewindCompletion.waitForDelayedResponse();
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
expect(rewindCompletion.timelineStreamCount()).toBe(0);
rewindCompletion.release();
await rewind;
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await expectComposerDraft(page, prompt);
await expect(page.getByTestId("user-message").filter({ hasText: rewoundPrompt })).toHaveCount(
0,
);
await expectComposerDraft(page, preservedDraft);
await expectNearBottom(page);
} finally {
await agent.cleanup();
}
@@ -0,0 +1,151 @@
import { test, expect } from "../support/fixtures";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import { startRunningMockAgent } from "../support/helpers/composer";
import {
sampleStreamFrames,
summarizeStreamSmoothness,
} from "../support/helpers/stream-smoothness";
/**
* Streaming smoothness, measured on the path the user actually sees.
*
* The mock provider's "bursty-stream" model emits tokens in uneven runs separated
* by idle gaps, which is how real models arrive. Sampling and the statistics live
* in ../support/helpers/stream-smoothness.ts, which also documents what the two
* numbers mean.
*
* Budgets are deliberately loose. This spec exists to catch a regression in the
* shape of the stream, not to pin exact numbers on shared CI hardware. For
* reference, on a local dev daemon the paced reveal measures a CV around 1.3–1.5
* against 2.7 when painting deltas as they arrive.
*/
const SAMPLE_WINDOW_MS = 6_000;
const CHARS_PER_FRAME_CV_BUDGET = 2;
const UPDATE_GAP_P95_BUDGET_MS = 250;
const REACT_COMMIT_P95_BUDGET_MS = 12;
const REACT_COMMIT_COUNT_BUDGET = 600;
const RUN_AGENT_STREAM_PERF = process.env.PASEO_AGENT_STREAM_PERF_E2E === "1";
const agentStreamPerfDescribe = RUN_AGENT_STREAM_PERF ? test.describe : test.describe.skip;
interface ReactCommit {
id: string;
phase: "mount" | "update" | "nested-update";
actualDuration: number;
baseDuration: number;
startTime: number;
commitTime: number;
}
agentStreamPerfDescribe("Agent stream smoothness", () => {
test("reveals bursty model output at a steady rate", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
});
const agent = await startRunningMockAgent(page, {
prefix: "stream-smoothness-",
model: "bursty-stream",
prompt: "Stream bursty output for smoothness measurement.",
});
try {
await awaitAssistantMessage(page);
await page.evaluate(() => {
const reset = Reflect.get(globalThis, "__PASEO_RESET_RENDER_PROFILE__");
if (typeof reset !== "function") {
throw new Error("Render profiler did not initialize");
}
reset();
});
const samples = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
const report = summarizeStreamSmoothness(samples, SAMPLE_WINDOW_MS);
const reactCommits = await readStreamReactCommits(page);
const reactReport = summarizeReactCommits(reactCommits);
await testInfo.attach("agent-stream-smoothness-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
await testInfo.attach("agent-stream-react-profile", {
body: JSON.stringify(reactReport, null, 2),
contentType: "application/json",
});
console.log(`[perf] Agent stream React: ${JSON.stringify(reactReport)}`);
console.log(`[perf] Agent stream smoothness: ${JSON.stringify(report)}`);
expect(
report.growthFrames,
"the stream must actually produce text during the sample window",
).toBeGreaterThan(20);
expect(
report.charsPerFrameCv,
`characters painted per frame should be steady (cv < ${CHARS_PER_FRAME_CV_BUDGET})`,
).toBeLessThan(CHARS_PER_FRAME_CV_BUDGET);
expect(
report.updateGapP95Ms,
`the text should not stall (p95 gap < ${UPDATE_GAP_P95_BUDGET_MS}ms)`,
).toBeLessThan(UPDATE_GAP_P95_BUDGET_MS);
expect(reactReport.count, "streaming should produce measured React commits").toBeGreaterThan(
20,
);
expect(
reactReport.count,
`the reveal should cap commits below ${REACT_COMMIT_COUNT_BUDGET} per sample window`,
).toBeLessThan(REACT_COMMIT_COUNT_BUDGET);
expect(
reactReport.actualDurationP95Ms,
`React commits should stay below ${REACT_COMMIT_P95_BUDGET_MS}ms at p95`,
).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
} finally {
await agent.cleanup();
}
});
});
async function readStreamReactCommits(
page: Parameters<typeof sampleStreamFrames>[0],
): Promise<ReactCommit[]> {
return await page.evaluate(() => {
const samples = Reflect.get(globalThis, "__PASEO_RENDER_PROFILE__");
if (!Array.isArray(samples)) {
return [];
}
return samples.filter(
(sample): sample is ReactCommit =>
typeof sample === "object" &&
sample !== null &&
typeof Reflect.get(sample, "id") === "string" &&
Reflect.get(sample, "id").startsWith("AgentStreamSection:"),
);
});
}
function summarizeReactCommits(commits: readonly ReactCommit[]) {
const durations = commits
.map((commit) => commit.actualDuration)
.sort((left, right) => left - right);
const total = durations.reduce((sum, duration) => sum + duration, 0);
return {
count: commits.length,
actualDurationTotalMs: round2(total),
actualDurationMeanMs: round2(commits.length > 0 ? total / commits.length : 0),
actualDurationP50Ms: percentile(durations, 50),
actualDurationP95Ms: percentile(durations, 95),
actualDurationMaxMs: durations.at(-1) ?? 0,
};
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function percentile(sorted: readonly number[], percentileValue: number): number {
if (sorted.length === 0) {
return 0;
}
return sorted[
Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * (percentileValue / 100)))
];
}
+36 -7
View File
@@ -838,25 +838,36 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
});
test("compact Changes keeps its options control touch-sized", async ({ page }) => {
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 });
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true });
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
const [optionsBox, glyphBox] = await Promise.all([
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
actions.boundingBox(),
options.boundingBox(),
options.locator("svg").boundingBox(),
]);
if (!optionsBox || !glyphBox) {
throw new Error("Compact Changes options geometry could not be measured");
if (!actionsBox || !optionsBox || !glyphBox) {
throw new Error("Compact Changes toolbar geometry could not be measured");
}
expect(actionsBox.width).toBe(48);
expect(actionsBox.height).toBe(28);
expect(optionsBox.width).toBe(32);
expect(optionsBox.height).toBe(32);
expect(glyphBox.width).toBe(18);
expect(glyphBox.height).toBe(18);
await expect(actions).not.toContainText("Commit");
await expect(actions.locator("svg")).toHaveCount(2);
await actions.click();
await expect(page.getByTestId("changes-primary-cta-menu")).toBeVisible();
await expect(page.getByTestId("changes-menu-commit")).toContainText("Commit");
await page.keyboard.press("Escape");
await options.click();
const wrapLines = page.getByText("Wrap long lines", { exact: true });
await expect(wrapLines).toBeVisible();
@@ -996,6 +1007,23 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
});
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
const focusedBackground = await changesTab.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(focusedBackground);
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
page,
}) => {
@@ -1095,7 +1123,7 @@ test("canvas diff copies a dragged character selection without opening a review"
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("the first click after a canvas selection only dismisses the selection", async ({ page }) => {
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
@@ -1105,7 +1133,7 @@ test("the first click after a canvas selection only dismisses the selection", as
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff replaces a selection with forward and backward drags", async ({
@@ -1537,7 +1565,8 @@ async function startReviewOnFirstChangedLine(
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
await page.getByRole("button", { name: "Add review comment" }).click();
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
}
@@ -0,0 +1,58 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
function mainPane(page: Page) {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function sidePanel(page: Page) {
return page.getByTestId("workspace-side-panel").filter({ visible: true });
}
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePanel(page)).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,132 @@
import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
function visibleMainPane(page: Page) {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
title: "Composer diff stat",
repo: { withRemote: true },
});
try {
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
await writeFile(
path.join(workspace.cwd, "README.md"),
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
);
await workspace.client.checkoutRefresh(workspace.cwd);
await expect
.poll(async () => {
const workspaces = await workspace.client.fetchWorkspaces();
return (
workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null
);
})
.toEqual({ additions: 2, deletions: 0 });
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-side-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const pill = page.getByTestId("composer-diff-stat-pill");
await expect(pill).toBeVisible({ timeout: 30_000 });
await expect(pill).toContainText("+2");
await expect(pill).toContainText("-0");
await pill.click();
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await test.step("switching from another Side panel view reveals Changes", async () => {
await openFilesPanel(page);
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await pill.click();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
await pill.click();
await expect(sidePanel).toHaveCount(0);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
});
await test.step("clicking again restores Changes", async () => {
await pill.click();
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
} finally {
await workspace.cleanup();
}
});
test("composer diff stat opens the compact explorer instead of a Changes tab", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-compact-");
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.getByTestId("composer-diff-stat-pill").click();
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.getByTestId("composer-diff-stat-pill").click();
const mainPane = visibleMainPane(page);
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,80 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visibleNewTabPanel(page: Page): Locator {
return page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
}
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
}
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+E`);
const launcher = page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-new-tab-panel");
await expect(launcher).toBeVisible({ timeout: 30_000 });
return launcher;
}
test.describe("New tab keyboard launcher", () => {
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const launcher = await openSidePanelWithKeyboard(page);
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter");
await expect(
page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-tab-files"),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
test("a new split pane focuses its launcher and supports arrow navigation", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleNewTabPanel(page);
await expect(launcher).toBeVisible({ timeout: 30_000 });
await expectLauncherSelection(launcher, "Agent");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Terminal");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter");
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await workspace.cleanup();
}
});
});
@@ -2,9 +2,8 @@
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
// MAIN pane in the background — the explorer pane is a background surface and must
// never swallow entity tabs or lose the user's focus, and
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
// interactive in the [agent | empty main | explorer] state (the reporter hit an
// app-wide lockup here with no way to close the empty pane).
// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
// interactive in the [agent | New | explorer] state.
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
@@ -53,19 +52,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
}
function emptyPaneChild(page: Page): Locator {
function newTabPaneChild(page: Page): Locator {
return page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
.filter({ has: page.getByTestId("workspace-new-tab-panel") })
.first();
}
async function emptyPaneBox(page: Page) {
const paneChild = emptyPaneChild(page);
const paneChild = newTabPaneChild(page);
await expect(paneChild).toBeVisible({ timeout: 15_000 });
const box = await paneChild.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Empty pane has no bounding box");
if (!box) throw new Error("New tab pane has no bounding box");
return box;
}
@@ -97,7 +96,7 @@ async function dragChipTo(
}
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
test("agents open in the main pane and New-tab splits keep the app usable", async ({
page,
}, testInfo) => {
const consoleErrors: string[] = [];
@@ -171,9 +170,9 @@ test.describe("explorer pane tab placement", () => {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
@@ -186,17 +185,12 @@ test.describe("explorer pane tab placement", () => {
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = emptyPaneChild(page);
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// The empty pane's + menu must open and dismiss cleanly.
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
await emptyPaneTrigger.click({ timeout: 5_000 });
await page.keyboard.press("Escape");
// A second drag must work: center-drop the agent tab back into the empty pane.
// A second drag must work: center-drop the agent tab back into the New pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
@@ -207,8 +201,8 @@ test.describe("explorer pane tab placement", () => {
contentType: "image/png",
});
// The agent chip must have moved into the previously empty pane and stay clickable.
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
// The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
@@ -71,7 +71,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(dockedSidebar(page)).toHaveCount(0);
await toggle.click();
@@ -108,7 +108,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await test.step("open the explorer so both sides are showing", async () => {
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(agentList).toHaveCount(1);
});
@@ -122,7 +122,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
});
} finally {
await workspace.cleanup();
+82 -4
View File
@@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-lockfile-",
title: "Large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "package-lock.json");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText('"package-0"');
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
} finally {
await session.cleanup();
}
});
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-plain-",
title: "Plain large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "plain.txt"),
"plain source\n".repeat(1_050_000),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "plain.txt");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText("plain source");
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
await expect(page.getByTestId("file-source-editor")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refuses a file above the display budget and keeps its tab recoverable", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-unsupported-",
title: "Unsupported large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "too-large.txt"),
Buffer.alloc(51 * 1024 * 1024),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "too-large.txt");
await expect(page.getByTestId("file-source-too-large")).toContainText(
"This file is too large to display",
);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("opens an assistant file link at its referenced line", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink({
@@ -413,10 +494,7 @@ test.describe("CodeMirror workspace file editing", () => {
.filter({ visible: true })
.first()
.click({ button: "right" });
await page
.getByTestId("workspace-tab-context-file_draft.ts-close")
.filter({ visible: true })
.click();
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
expect(closePrompt).toContain("Closing it will discard the draft.");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
finalText: string,
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
) {
await page.evaluate(() => {
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
(
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
).__fileTransitionOriginalChip = originalChip;
});
await page.evaluate((tabTestId) => {
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
const sample = () => {
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
const chipIdentity = await page.evaluate((tabTestId) => {
const windowWithOriginal = window as typeof window & {
__fileTransitionOriginalChip?: Element | null;
};
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
return {
originalStillConnected: originalChip?.isConnected ?? false,
sameNode: originalChip === currentChip,
};
}, `workspace-tab-file_${targetPath}`);
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
const heldTimeline = await page.evaluate(
() =>
(
@@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
let workspace: SeededWorkspace;
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
return locator.evaluate((element) => {
let current: Element | null = element;
while (current) {
const color = getComputedStyle(current).backgroundColor;
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
current = current.parentElement;
}
return "transparent";
});
}
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "file-tree-rail-toggle-",
@@ -21,6 +33,21 @@ test.afterAll(async () => {
});
test.describe("File panel tree rail", () => {
test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
await expect(rail).toBeVisible({ timeout: 30_000 });
const content = rail.getByTestId("files-tree-rail-content");
const tree = rail.getByTestId("files-tree-rail-tree");
await expect(content).toHaveText("Choose a file");
await expect(tree).toContainText("docs");
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
await effectiveBackground(content),
);
});
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
+100 -54
View File
@@ -1,11 +1,11 @@
import { test, expect } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertNewTabMenuTriggerVisible,
assertSingleNewTabButton,
openNewTabMenuWithShortcut,
pressDirectNewTabShortcut,
clickNewChat,
clickNewTerminal,
countTabsOfKind,
@@ -45,6 +45,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
};
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
return tabs.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
);
}
function tabIdentityKey(snapshot: Array<{ id: string }>): string {
return JSON.stringify(snapshot.map(({ id }) => id));
}
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
const created = await workspace.client.createWorkspace({
@@ -66,14 +76,12 @@ test.afterAll(async () => {
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => {
test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => {
test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => {
if (!secondWorkspaceId) {
throw new Error("Secondary workspace was not created");
}
const serverId = getServerId();
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
const switchWorkspaceRow = async (workspaceId: string) => {
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -92,11 +100,12 @@ test.describe("Tab creation", () => {
for (let i = 0; i < 8; i++) {
await switchWorkspaceRow(sequence[i % sequence.length]);
await pressNewTabShortcut(page);
await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
`New tab did not open after switch ${i}`,
).toBeVisible({
timeout: 5_000,
});
await page.keyboard.press("Escape");
await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 });
}
});
@@ -104,59 +113,76 @@ test.describe("Tab creation", () => {
await gotoWorkspace(page, workspace.workspaceId);
const workspaceUrl = page.url();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
await page.keyboard.press(`${modifier}+Comma`);
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
await page.keyboard.press(`${modifier}+t`);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await page.goto(workspaceUrl);
await assertNewTabMenuTriggerVisible(page);
const countBefore = await countTabsOfKind(page, "draft");
await pressDirectNewTabShortcut(page, "a");
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
});
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
await openNewTabMenuWithShortcut(page);
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "new_tab");
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
).toBeVisible();
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
});
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
const countBefore = await newTabs.count();
await pressDirectNewTabShortcut(page, "a");
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countBefore + 1);
const countAfterFirst = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 1);
const firstIds = await tabTestIds(newTabs);
await pressDirectNewTabShortcut(page, "a");
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countAfterFirst + 1);
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 2);
const ids = await tabTestIds(newTabs);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
});
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openNewTabMenuWithShortcut(page);
const agent = page.getByRole("menuitem", { name: /^Agent/ });
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
const changes = page.getByRole("menuitem", { name: /Changes/ });
const files = page.getByRole("menuitem", { name: /Files/ });
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
const agent = panel.getByRole("button", { name: /^Agent/ });
const terminal = panel.getByRole("button", { name: /^Terminal/ });
const changes = panel.getByRole("button", { name: /Changes/ });
const files = panel.getByRole("button", { name: /Files/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(agent).toBeFocused();
@@ -164,12 +190,10 @@ test.describe("Tab creation", () => {
await expect(terminal).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
"aria-expanded",
"false",
);
await page.locator("body").click({ position: { x: 1, y: 1 } });
await panel.click({ position: { x: 20, y: 20 } });
await expect(agent).toBeFocused();
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
@@ -205,8 +229,12 @@ test.describe("Tab creation", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page
.getByTestId("workspace-new-tab-menu")
.filter({ visible: true })
.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name })
.click();
await expectTerminalOutputContains(page, "prompt-args: 0");
} finally {
@@ -214,6 +242,20 @@ test.describe("Tab creation", () => {
}
});
test("terminal profiles are grouped with a settings action", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles");
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
await editProfiles.click();
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
@@ -226,15 +268,13 @@ test.describe("Tab creation", () => {
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
const newTabButtonInScroll = await scrollArea
.getByTestId("workspace-new-tab-menu-trigger")
.count();
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
const scrollShades = await tabRow
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
.count();
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
});
});
@@ -333,28 +373,34 @@ test.describe("Terminal title propagation", () => {
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
test("New agent tab transition has no blank intermediate tab state", async ({
page,
withWorkspace,
}) => {
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
await isolatedWorkspace.navigateTo();
await pressNewTabShortcut(page);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
// Sample the single New → Agent replacement, not the separate action that
// creates the New tab in the first place.
const snapshots = await sampleTabsDuringTransition(page, async () => {
await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click();
});
// Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) {
expect(snapshot.length).toBeGreaterThanOrEqual(1);
}
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
// When running in-suite, previous tests may have created tabs on the shared workspace,
// so we allow +2 tolerance for accumulated state and React render batching.
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
const counts = snapshots.map((snapshot) => snapshot.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0;
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
2,
);
expect(counts.every((count) => count === initialCount)).toBe(true);
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
});
@@ -0,0 +1,49 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
test("a workspace with an existing agent never presents New during startup", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "new-tab-startup-agent-" });
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Existing startup agent",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "ready",
});
try {
await page.addInitScript(() => {
Reflect.set(globalThis, "__paseoSawVisibleNewTab", false);
function recordVisibleNewTab() {
const panel = document.querySelector('[data-testid="workspace-new-tab-panel"]');
if (panel?.getClientRects().length) {
Reflect.set(globalThis, "__paseoSawVisibleNewTab", true);
}
}
window.addEventListener("DOMContentLoaded", () => {
new MutationObserver(recordVisibleNewTab).observe(document.documentElement, {
childList: true,
subtree: true,
});
recordVisibleNewTab();
});
});
await gotoWorkspace(page, workspace.workspaceId);
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toHaveCount(
0,
);
expect(await page.evaluate(() => Reflect.get(globalThis, "__paseoSawVisibleNewTab"))).toBe(
false,
);
} finally {
await workspace.cleanup();
}
});
+3 -3
View File
@@ -102,11 +102,11 @@ test.describe("PR pane", () => {
await expectPrPaneTitle(page, "Work in progress");
});
test("renders check pills with correct passed/failed/pending counts", async ({ page }) => {
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
});
test("renders activity rows with correct count", async ({ page }) => {
@@ -120,7 +120,7 @@ test.describe("PR pane", () => {
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
await expectPrPaneTitle(page, "PR with no checks");
});
});
@@ -0,0 +1,50 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "@playwright/test";
import {
beginReplicaCacheObservation,
finishReplicaCacheObservation,
installReplicaCacheMeasurement,
measureReplicaCacheRestore,
type ReplicaCacheMeasurementReport,
} from "../support/helpers/replica-cache-measurement";
const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1";
const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL;
const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT;
const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000);
const HYDRATED_SELECTOR =
process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ??
'[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]';
const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip;
measurementDescribe("Replica cache live measurement", () => {
test("observes persistence and cached restore without mutating the daemon", async ({
page,
}, testInfo) => {
test.setTimeout(OBSERVATION_MS + 90_000);
expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy();
expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy();
await installReplicaCacheMeasurement(page);
await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle").catch(() => undefined);
const observation = await beginReplicaCacheObservation(page);
await page.waitForTimeout(OBSERVATION_MS);
const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!);
const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR);
const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs };
await mkdir(path.dirname(REPORT_PATH!), { recursive: true });
await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`);
await testInfo.attach("replica-cache-measurement", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`);
expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS);
});
});
@@ -14,7 +14,7 @@ import {
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
const MAX_STREAM_WRITES = 12;
const MAX_SERIALIZED_CHARS = 190_000;
const MAX_SERIALIZED_CHARS = 45_000;
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
replicaCacheAgent: async ({ page: _page }, provide) => {
+47 -26
View File
@@ -18,18 +18,20 @@ function mainPane(page: Page): Locator {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function emptyState(scope: Locator): Locator {
return scope.getByTestId("workspace-pane-empty-state");
function newTabPanel(scope: Locator): Locator {
return scope.getByTestId("workspace-new-tab-panel");
}
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
return launcher
.getByRole("button")
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
}
function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first();
}
function closePaneButton(page: Page): Locator {
return page.getByRole("button", { name: "Close pane" });
}
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
const paneBox = await mainPane(page).boundingBox();
@@ -61,13 +63,20 @@ test.describe("Side panel", () => {
await test.step("revealing the Side panel seeds nothing", async () => {
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
const launcher = newTabPanel(sidePanel(page));
await expect(launcher).toBeVisible();
expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([
"Changes",
"Files",
"Terminal",
"Agent",
]);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
await capture(page, testInfo, "01-side-panel-reveals-empty");
});
await test.step("Changes opens in the Side panel that offered it", async () => {
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
@@ -77,10 +86,6 @@ test.describe("Side panel", () => {
await capture(page, testInfo, "02-changes-opens-in-side-panel");
});
await test.step("a loaded pane offers no one-tap close", async () => {
await expect(closePaneButton(page)).toHaveCount(0);
});
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
await changesTab.hover();
@@ -93,7 +98,7 @@ test.describe("Side panel", () => {
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
await expect(newTabPanel(sidePanel(page))).toBeVisible();
await capture(page, testInfo, "04-side-panel-revealed-empty");
});
} finally {
@@ -123,23 +128,30 @@ test.describe("Side panel", () => {
.first();
await expect(draftPane).toBeVisible({ timeout: 15_000 });
const emptyMain = emptyState(mainPane(page));
const emptyMain = newTabPanel(mainPane(page));
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
await capture(page, testInfo, "06-empty-main-pane-launcher");
await emptyMain.getByRole("button", { name: "Close pane" }).click();
const newTabChip = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await newTabChip.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.click();
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
await expect(sidePanel(page)).toBeVisible();
await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0);
await expect(draftPane.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
await capture(page, testInfo, "07-empty-main-pane-removed");
} finally {
await workspace.cleanup();
}
});
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
test("closing New cannot remove the last visible pane", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
try {
@@ -150,7 +162,9 @@ test.describe("Side panel", () => {
// holds that tab, so emptying Main does not look like an empty workspace and
// nothing reseeds a draft into it.
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
await newTabPanel(sidePanel(page))
.getByRole("button", { name: "Files", exact: true })
.click();
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
timeout: 30_000,
});
@@ -168,13 +182,20 @@ test.describe("Side panel", () => {
.first()
.click();
// Main is now empty and the only pane the user can see. Taking it away would
// leave nowhere to look, so the launcher appears without a Close pane.
const lastPane = emptyState(mainPane(page));
// Main now contains New and is the only pane the user can see. Closing its
// tab cannot remove the final visible pane.
const lastPane = newTabPanel(mainPane(page));
await expect(lastPane).toBeVisible({ timeout: 15_000 });
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible();
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
await expect(closePaneButton(page)).toHaveCount(0);
await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
const finalNewTab = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await finalNewTab.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.click();
await expect(lastPane).toBeVisible();
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
} finally {
await workspace.cleanup();
@@ -190,13 +211,13 @@ test.describe("Side panel", () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "08-changes-in-side-panel");
await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click();
await mainPane(page).getByTestId("workspace-new-tab-button").click();
await page
.getByTestId("workspace-new-tab-menu-changes")
.filter({ visible: true })
@@ -1,6 +1,10 @@
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
expectNewWorkspaceProjectSelected,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
@@ -142,6 +146,25 @@ async function installPinRpcGate(
}
test.describe("Pin workspace shortcut", () => {
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "pin-project-creation-",
title: "Pinned workspace",
});
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await openNewWorkspaceComposer(page, workspace);
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
} finally {
await workspace.cleanup();
}
});
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
beforeXterm.rows,
);
// Split the pane downward. This focuses the new empty pane, so the terminal
// Split the pane downward. This focuses the new tab pane, so the terminal
// pane is unfocused at the exact moment its container shrinks.
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
@@ -59,8 +59,12 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
// Workspaces always render a hidden explorer companion pane alongside the
// main pane, so an unscoped testid locator matches both; scope to the
// visible one.
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByTestId("workspace-new-tab-menu-terminal").click();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page
.getByTestId("workspace-new-tab-menu-terminal")
.filter({ visible: true })
.first()
.click();
}
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
@@ -128,7 +132,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
).toBeVisible({
timeout: 30_000,
});
@@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => {
.toBe(promptTitle);
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
await agentTab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press("Escape");
@@ -65,10 +65,10 @@ test.describe("Workspace pane mounting", () => {
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const copyTerminalId = page.getByTestId(
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
);
const copyTerminalId = page.getByRole("menuitem", {
name: /^Copy terminal id/,
});
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
await copyTerminalId.click();
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
+6
View File
@@ -196,6 +196,12 @@ async function loadHarnessEnvironment(repoRoot: string): Promise<void> {
}
export default async function globalSetup() {
if (process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1") {
if (!process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL) {
throw new Error("PASEO_REPLICA_CACHE_MEASUREMENT_URL must be set for live measurement");
}
return;
}
const repoRoot = path.resolve(__dirname, "../../../..");
await loadHarnessEnvironment(repoRoot);
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
}
if (draft.notes !== undefined) {
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes);
}
}
@@ -329,11 +329,18 @@ export async function expectAgentProfileForm(
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
const notesHint = modal.getByTestId("agent-profile-notes-field-hint");
await expect(notesHint).toHaveText(
"Helps agents choose this profile when starting another agent.",
);
expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe(
"1",
);
for (const [field, value] of Object.entries(expected)) {
if (field === "notes") {
await expect(
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
).toHaveValue(value);
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
value,
);
continue;
}
await expect(
@@ -27,6 +27,13 @@ export interface DaemonHydrationGate {
release(): void;
}
export interface RewindCompletionGate {
clearTimelineStreamCount(): void;
release(): void;
timelineStreamCount(): number;
waitForDelayedResponse(): Promise<void>;
}
export interface PromptJumpRequestTracker {
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
}
@@ -255,6 +262,62 @@ export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGa
};
}
export async function holdRewindCompletion(
page: Page,
agentId: string,
): Promise<RewindCompletionGate> {
let released = false;
let timelineStreamCount = 0;
const delayedForwards: Array<() => void> = [];
let resolveDelayedResponse: (() => void) | null = null;
const delayedResponse = new Promise<void>((resolve) => {
resolveDelayedResponse = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
const streamEvent = payload?.event;
if (
sessionMessage?.type === "agent_stream" &&
payload?.agentId === agentId &&
streamEvent &&
typeof streamEvent === "object" &&
(streamEvent as { type?: unknown }).type === "timeline"
) {
timelineStreamCount += 1;
}
if (
!released &&
sessionMessage?.type === "agent.rewind.response" &&
payload?.agentId === agentId
) {
delayedForwards.push(() => ws.send(message));
resolveDelayedResponse?.();
return;
}
ws.send(message);
});
});
return {
clearTimelineStreamCount() {
timelineStreamCount = 0;
},
release() {
released = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
timelineStreamCount: () => timelineStreamCount,
waitForDelayedResponse: () => delayedResponse,
};
}
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
+10 -13
View File
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to create a New tab in the focused pane. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`);
@@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
await pressNewTabShortcut(page);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toBeVisible();
}
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
@@ -83,30 +83,27 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
/** Assert the inline plus button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
/** Assert the New tab button is visible in the tab bar. */
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
/** Choose Agent from the pane-local `+` menu. */
export async function clickNewChat(page: Page): Promise<void> {
await createAgentTabFromMenu(page);
}
/** Open the new-tab menu and click "New terminal". */
/** Choose Terminal from the pane-local `+` menu. */
export async function clickNewTerminal(page: Page): Promise<void> {
const trigger = page
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = page
@@ -137,7 +134,7 @@ export async function waitForTabWithTitle(
/** Assert the inline plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
@@ -224,7 +221,7 @@ export async function expectTabTitleFits(
}
export function terminalSurfaceLocator(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
return page.locator('[data-testid="terminal-surface"]').filter({ visible: true }).first();
}
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
export interface MockAgentOptions {
repoPrefix: string;
title: string;
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
port?: number;
initialPrompt?: string;
model?: string;
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
const workspace = await seedWorkspace({
repoPrefix: options.repoPrefix,
repo: options.repo,
port: options.port,
});
try {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -113,6 +113,11 @@ export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
export async function expectPairingOffer(page: Page): Promise<void> {
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
await expect(
page.getByRole("alert").filter({
hasText: "Treat this pairing link like a password. Anyone with it can access this daemon.",
}),
).toBeVisible();
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
}
+4 -3
View File
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test";
import type { CheckPresentationCounts } from "@/git/check-presentation";
import { getStateLabel } from "@/git/pull-request-panel/data";
import { openPullRequestPanel } from "./workspace-tabs";
@@ -29,10 +30,10 @@ async function assertCheckPill(page: Page, testId: string, count: number): Promi
export async function expectPrPaneCheckSummary(
page: Page,
counts: { passed: number; failed: number; pending: number },
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
): Promise<void> {
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
await assertCheckPill(page, "pr-pane-check-success", counts.success);
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
}
@@ -0,0 +1,312 @@
import type { CDPSession, Page } from "@playwright/test";
const REPLICA_CACHE_DATABASE = "paseo-replica-row-store";
const REPLICA_CACHE_ROWS_STORE = "rows";
const PERSIST_FRAME_NAMES = [
"captureSessions",
"captureHost",
"queueReplicaChanges",
"apply",
"safeParse",
"stringify",
"setItem",
"measuredPut",
] as const;
const PERSIST_STACK_ANCHORS = new Set([
"captureSessions",
"captureHost",
"queueReplicaChanges",
"persist",
"flushPending",
"measuredPut",
]);
export interface ReplicaCacheMeasurementReport {
targetUrl: string;
observationMs: number;
persistCpuMs: number;
persistCpuFrames: Record<string, number>;
flushes: number;
bytesWritten: {
total: number;
perFlush: number[];
};
redundantWrites: number;
storageDurationMs: number;
longTasks: {
count: number;
totalDurationMs: number;
maximumDurationMs: number;
durationsMs: number[];
};
restoreMs: number | null;
}
interface BrowserMeasurementState {
flushes: Array<{ bytes: number; durationMs: number }>;
longTasks: number[];
redundantWrites: number;
}
interface ProfileNode {
id: number;
callFrame: { functionName: string; url: string };
parent?: number;
}
interface SamplingProfile {
nodes: ProfileNode[];
samples?: number[];
timeDeltas?: number[];
}
declare global {
interface Window {
__replicaCacheMeasurement?: BrowserMeasurementState;
__replicaCacheRestoreReadStartedAt?: number;
}
}
export async function installReplicaCacheMeasurement(page: Page): Promise<void> {
await page.addInitScript(
({ databaseName, storeName }) => {
const encoder = new TextEncoder();
const transactionWrites = new WeakMap<
IDBTransaction,
Array<
| { type: "put"; key: string; value: string; bytes: number; startedAt: number }
| { type: "delete"; key: string; bytes: 0; startedAt: number }
>
>();
const lastValues = new Map<string, string>();
const resolveStorageKey = (
store: IDBObjectStore,
value: unknown,
explicitKey: IDBValidKey | undefined,
): unknown => {
if (explicitKey !== undefined) return explicitKey;
if (value === null || typeof value !== "object") return null;
if (Array.isArray(store.keyPath)) {
return store.keyPath.map((part) => Reflect.get(value, part));
}
return typeof store.keyPath === "string" ? Reflect.get(value, store.keyPath) : null;
};
window.__replicaCacheMeasurement = {
flushes: [],
longTasks: [],
redundantWrites: 0,
};
const observeTransaction = (transaction: IDBTransaction) => {
let writes = transactionWrites.get(transaction);
if (writes) return writes;
writes = [];
transactionWrites.set(transaction, writes);
transaction.addEventListener("complete", () => {
const state = window.__replicaCacheMeasurement;
if (!state || writes!.length === 0) return;
let bytes = 0;
let startedAt = Number.POSITIVE_INFINITY;
for (const write of writes!) {
bytes += write.bytes;
startedAt = Math.min(startedAt, write.startedAt);
if (write.type === "put") {
if (lastValues.get(write.key) === write.value) state.redundantWrites += 1;
lastValues.set(write.key, write.value);
} else {
lastValues.delete(write.key);
}
}
state.flushes.push({ bytes, durationMs: performance.now() - startedAt });
});
return writes;
};
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
const request =
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
const rowPayload =
value && typeof value === "object" ? Reflect.get(value, "payload") : undefined;
let serialized = JSON.stringify(value);
if (typeof value === "string") serialized = value;
if (typeof rowPayload === "string") serialized = rowPayload;
const storageKey = JSON.stringify(resolveStorageKey(this, value, key));
observeTransaction(this.transaction).push({
type: "put",
key: `${this.name}:${storageKey}`,
value: serialized,
bytes: encoder.encode(serialized).byteLength,
startedAt: performance.now(),
});
return request;
};
const originalDelete = IDBObjectStore.prototype.delete;
IDBObjectStore.prototype.delete = function measuredDelete(query: IDBValidKey | IDBKeyRange) {
const request = originalDelete.call(this, query);
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
observeTransaction(this.transaction).push({
type: "delete",
key: `${this.name}:${JSON.stringify(query)}`,
bytes: 0,
startedAt: performance.now(),
});
return request;
};
const markRestoreRead = (store: IDBObjectStore) => {
if (
store.transaction.db.name === databaseName &&
store.name === storeName &&
store.transaction.mode === "readonly" &&
window.__replicaCacheRestoreReadStartedAt === undefined
) {
window.__replicaCacheRestoreReadStartedAt = performance.now();
}
};
const originalGet = IDBObjectStore.prototype.get;
IDBObjectStore.prototype.get = function measuredGet(query: IDBValidKey | IDBKeyRange) {
markRestoreRead(this);
return originalGet.call(this, query);
};
const originalGetAll = IDBObjectStore.prototype.getAll;
IDBObjectStore.prototype.getAll = function measuredGetAll(
query?: IDBValidKey | IDBKeyRange | null,
count?: number,
) {
markRestoreRead(this);
return originalGetAll.call(this, query, count);
};
const originalOpenCursor = IDBObjectStore.prototype.openCursor;
IDBObjectStore.prototype.openCursor = function measuredOpenCursor(
query?: IDBValidKey | IDBKeyRange | null,
direction?: IDBCursorDirection,
) {
markRestoreRead(this);
return originalOpenCursor.call(this, query, direction);
};
if (typeof PerformanceObserver !== "undefined") {
const observer = new PerformanceObserver((entries) => {
const state = window.__replicaCacheMeasurement;
if (!state) return;
for (const entry of entries.getEntries()) state.longTasks.push(entry.duration);
});
try {
observer.observe({ type: "longtask", buffered: true });
} catch {
// Chromium exposes Long Tasks; leave an empty list on runtimes that do not.
}
}
},
{ databaseName: REPLICA_CACHE_DATABASE, storeName: REPLICA_CACHE_ROWS_STORE },
);
}
export async function beginReplicaCacheObservation(
page: Page,
): Promise<{ cdp: CDPSession; startedAt: number }> {
await page.evaluate(() => {
const state = window.__replicaCacheMeasurement;
if (!state) throw new Error("Replica cache measurement is not installed");
state.flushes = [];
state.longTasks = [];
state.redundantWrites = 0;
});
const cdp = await page.context().newCDPSession(page);
await cdp.send("Profiler.enable");
await cdp.send("Profiler.setSamplingInterval", { interval: 100 });
await cdp.send("Profiler.start");
return { cdp, startedAt: Date.now() };
}
export async function finishReplicaCacheObservation(
page: Page,
observation: { cdp: CDPSession; startedAt: number },
targetUrl: string,
): Promise<Omit<ReplicaCacheMeasurementReport, "restoreMs">> {
const result = (await observation.cdp.send("Profiler.stop")) as { profile: SamplingProfile };
await observation.cdp.detach();
const browser = await page.evaluate(() => {
const state = window.__replicaCacheMeasurement;
if (!state) throw new Error("Replica cache measurement is not installed");
return state;
});
const cpu = summarizePersistCpu(result.profile);
const bytesPerFlush = browser.flushes.map((flush) => flush.bytes);
const longTaskDurations = browser.longTasks;
return {
targetUrl,
observationMs: Date.now() - observation.startedAt,
persistCpuMs: cpu.totalMs,
persistCpuFrames: cpu.byFrame,
flushes: browser.flushes.length,
bytesWritten: {
total: bytesPerFlush.reduce((sum, bytes) => sum + bytes, 0),
perFlush: bytesPerFlush,
},
redundantWrites: browser.redundantWrites,
storageDurationMs: browser.flushes.reduce((sum, flush) => sum + flush.durationMs, 0),
longTasks: {
count: longTaskDurations.length,
totalDurationMs: longTaskDurations.reduce((sum, duration) => sum + duration, 0),
maximumDurationMs: Math.max(0, ...longTaskDurations),
durationsMs: longTaskDurations,
},
};
}
export async function measureReplicaCacheRestore(
page: Page,
targetUrl: string,
hydratedSelector: string,
): Promise<number | null> {
await page.routeWebSocket(/\/ws(?:\?|$)/, async (socket) => {
await socket.close({ code: 1000, reason: "Hold reconnect until cached replica paints" });
});
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
const restoredAt = await page.waitForFunction(
(selector) => {
const startedAt = window.__replicaCacheRestoreReadStartedAt;
if (startedAt === undefined) return null;
const element = document.querySelector(selector);
if (!element || !(element instanceof HTMLElement) || element.offsetParent === null)
return null;
return performance.now();
},
hydratedSelector,
{ timeout: 30_000 },
);
const restoredAtMs = await restoredAt.jsonValue();
const startedAtMs = await page.evaluate(() => window.__replicaCacheRestoreReadStartedAt ?? null);
return typeof restoredAtMs === "number" && startedAtMs !== null
? restoredAtMs - startedAtMs
: null;
}
function summarizePersistCpu(profile: SamplingProfile): {
totalMs: number;
byFrame: Record<string, number>;
} {
const nodes = new Map(profile.nodes.map((node) => [node.id, node]));
const byFrame: Record<string, number> = {};
let totalMicroseconds = 0;
for (const [index, sampleId] of (profile.samples ?? []).entries()) {
const delta = profile.timeDeltas?.[index] ?? 0;
const stackFunctions = new Set<string>();
let node = nodes.get(sampleId);
while (node) {
stackFunctions.add(node.callFrame.functionName);
node = node.parent === undefined ? undefined : nodes.get(node.parent);
}
if (![...stackFunctions].some((name) => PERSIST_STACK_ANCHORS.has(name))) continue;
const matched = PERSIST_FRAME_NAMES.filter((name) => stackFunctions.has(name));
if (matched.length === 0) continue;
totalMicroseconds += delta;
for (const pattern of matched) byFrame[pattern] = (byFrame[pattern] ?? 0) + delta / 1_000;
}
return { totalMs: totalMicroseconds / 1_000, byFrame };
}
@@ -1,26 +1,49 @@
import type { Page } from "@playwright/test";
const DATABASE_NAME = "paseo-replica-cache";
const STORE_NAME = "key-value";
const STORAGE_KEY = "@paseo:replica-cache";
const DATABASE_NAME = "paseo-replica-row-store";
const STORE_NAME = "rows";
const SINGLETON_ID = "singleton";
type ReplicaRowKind = "agent" | "workspace" | "project" | "timeline" | "checkpoint";
interface ReplicaRowRecord {
serverId: string;
kind: ReplicaRowKind;
id: string;
payload: string;
}
interface ReplicaCacheHostRecord {
serverId: string;
agents: Array<Record<string, unknown>>;
workspaces: Array<Record<string, unknown>>;
projects: Array<Record<string, unknown>>;
timeline?: {
agentId?: string;
items?: Array<Record<string, unknown>>;
range?: {
endSeq?: number;
epoch?: string;
startSeq?: number;
} | null;
} | null;
directorySync?: unknown;
}
export interface ReplicaCacheRecord {
version?: number;
hosts?: Array<{
timeline?: {
agentId?: string;
items?: Array<Record<string, unknown>>;
range?: {
endSeq?: number;
epoch?: string;
startSeq?: number;
} | null;
} | null;
}>;
hosts?: ReplicaCacheHostRecord[];
}
interface ReplicaCacheWriteObserverState {
lastValue: string | null;
lastValues: Record<string, string>;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
}
interface ReplicaCacheWriteObserverReport {
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
@@ -33,11 +56,10 @@ declare global {
}
}
async function readStoredValue(input: {
async function readRows(input: {
databaseName: string;
storeName: string;
storageKey: string;
}): Promise<string | null> {
}): Promise<ReplicaRowRecord[]> {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(input.databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
@@ -47,26 +69,103 @@ async function readStoredValue(input: {
const request = database
.transaction(input.storeName, "readonly")
.objectStore(input.storeName)
.get(input.storageKey);
request.addEventListener("success", () =>
resolve(typeof request.result === "string" ? request.result : null),
);
.getAll();
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
}
function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord {
const hosts = new Map<string, ReplicaCacheHostRecord>();
for (const row of rows) {
const host = hosts.get(row.serverId) ?? {
serverId: row.serverId,
agents: [],
workspaces: [],
projects: [],
timeline: null,
};
const payload = JSON.parse(row.payload) as Record<string, unknown>;
switch (row.kind) {
case "agent":
host.agents.push(payload);
break;
case "workspace":
host.workspaces.push(payload);
break;
case "project":
host.projects.push(payload);
break;
case "timeline":
host.timeline = payload;
break;
case "checkpoint":
host.directorySync = payload;
break;
}
hosts.set(row.serverId, host);
}
return { version: 6, hosts: [...hosts.values()] };
}
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
const raw = await page.evaluate(readStoredValue, {
const rows = await page.evaluate(readRows, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
return rows.length > 0 ? assembleCache(rows) : null;
}
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
const rows: ReplicaRowRecord[] = [];
for (const host of value.hosts ?? []) {
for (const agent of host.agents) {
const snapshot = agent.snapshot;
if (!snapshot || typeof snapshot !== "object") continue;
const id = Reflect.get(snapshot, "id");
if (typeof id === "string") {
rows.push({ serverId: host.serverId, kind: "agent", id, payload: JSON.stringify(agent) });
}
}
for (const workspace of host.workspaces) {
if (typeof workspace.id === "string") {
rows.push({
serverId: host.serverId,
kind: "workspace",
id: workspace.id,
payload: JSON.stringify(workspace),
});
}
}
for (const project of host.projects) {
if (typeof project.projectId === "string") {
rows.push({
serverId: host.serverId,
kind: "project",
id: project.projectId,
payload: JSON.stringify(project),
});
}
}
if (host.timeline) {
rows.push({
serverId: host.serverId,
kind: "timeline",
id: SINGLETON_ID,
payload: JSON.stringify(host.timeline),
});
}
if (host.directorySync !== undefined) {
rows.push({
serverId: host.serverId,
kind: "checkpoint",
id: SINGLETON_ID,
payload: JSON.stringify(host.directorySync),
});
}
}
await page.evaluate(
async ({ databaseName, storeName, storageKey, raw }) => {
async ({ databaseName, storeName, nextRows }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
@@ -74,26 +173,23 @@ export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord):
});
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction(storeName, "readwrite");
transaction.objectStore(storeName).put(raw, storageKey);
const store = transaction.objectStore(storeName);
store.clear();
for (const row of nextRows) store.put(row);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () => reject(transaction.error));
transaction.addEventListener("abort", () => reject(transaction.error));
});
},
{
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
raw: JSON.stringify(value),
},
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, nextRows: rows },
);
}
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
await page.addInitScript(
({ databaseName, storeName, storageKey }) => {
({ databaseName, storeName }) => {
window.__replicaCacheWriteObserver = {
lastValue: null,
lastValues: {},
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
@@ -106,51 +202,71 @@ export async function observeReplicaCacheStorageWrites(page: Page): Promise<void
if (
this.transaction.db.name !== databaseName ||
this.name !== storeName ||
key !== storageKey ||
typeof value !== "string"
!value ||
typeof value !== "object"
) {
return request;
}
const serverId = Reflect.get(value, "serverId");
const kind = Reflect.get(value, "kind");
const id = Reflect.get(value, "id");
const payload = Reflect.get(value, "payload");
if (
typeof serverId !== "string" ||
typeof kind !== "string" ||
typeof id !== "string" ||
typeof payload !== "string"
) {
return request;
}
const rowKey = `${serverId}:${kind}:${id}`;
const startedAt = performance.now();
request.addEventListener("success", () => {
const state = window.__replicaCacheWriteObserver;
if (!state) return;
if (state.lastValue === value) state.redundantWrites += 1;
state.lastValue = value;
if (state.lastValues[rowKey] === payload) state.redundantWrites += 1;
state.lastValues[rowKey] = payload;
state.writes += 1;
state.serializedChars += value.length;
state.serializedChars += payload.length;
state.storageDurationMs += performance.now() - startedAt;
});
return request;
};
},
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
{ databaseName: DATABASE_NAME, storeName: STORE_NAME },
);
}
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
const lastValue = await page.evaluate(readStoredValue, {
const rows = await page.evaluate(readRows, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
await page.evaluate((value) => {
const lastValues = Object.fromEntries(
rows.map((row) => [`${row.serverId}:${row.kind}:${row.id}`, row.payload]),
);
await page.evaluate((values) => {
window.__replicaCacheWriteObserver = {
lastValue: value,
lastValues: values,
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
}, lastValue);
}, lastValues);
}
export async function readReplicaCacheStorageWriteObserver(
page: Page,
): Promise<ReplicaCacheWriteObserverState> {
): Promise<ReplicaCacheWriteObserverReport> {
return page.evaluate(() => {
const state = window.__replicaCacheWriteObserver;
if (!state) throw new Error("Replica cache write observer is not installed");
return state;
return {
redundantWrites: state.redundantWrites,
serializedChars: state.serializedChars,
storageDurationMs: state.storageDurationMs,
writes: state.writes,
};
});
}
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
diffStat: { additions: number; deletions: number } | null;
labels?: string[];
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { summarizeStreamSmoothness } from "./stream-smoothness";
describe("summarizeStreamSmoothness", () => {
it("reports canonical handover snaps separately from paced growth", () => {
const report = summarizeStreamSmoothness(
[
{ timestampMs: 0, growthChars: 0, firstPaintChars: 0 },
{ timestampMs: 10, growthChars: 4, firstPaintChars: 0 },
{ timestampMs: 20, growthChars: 100, firstPaintChars: 30 },
{ timestampMs: 30, growthChars: 4, firstPaintChars: 0 },
{ timestampMs: 40, growthChars: 0, firstPaintChars: 0 },
],
40,
);
expect(report).toMatchObject({
frames: 3,
growthFrames: 2,
charsPainted: 8,
maxCharsInOneFrame: 4,
firstPaintFrames: 1,
firstPaintChars: 30,
handoverGrowthFrames: 1,
handoverGrowthChars: 100,
maxHandoverGrowthCharsInOneFrame: 100,
updateGapP95Ms: 10,
});
});
});
@@ -0,0 +1,215 @@
import type { Page } from "@playwright/test";
/**
* Frame-level sampling of the streaming assistant message, and the two numbers
* that describe how it reads:
*
* - `charsPerFrameCv` — coefficient of variation of characters painted per frame.
* This is the smoothness metric. Painting deltas as they land tracks the
* arrival lumps, so it is high; pacing the reveal flattens it.
* - `updateGapP95Ms` — how long the text can sit unchanged mid-stream. This is
* the stall metric, and it is what stops smoothness being bought with lag.
*
* Both are needed. A stream that never updates is perfectly smooth.
*/
export interface StreamFrameSample {
timestampMs: number;
growthChars: number;
firstPaintChars: number;
}
export interface StreamSmoothnessReport {
sampleWindowMs: number;
frames: number;
growthFrames: number;
charsPainted: number;
meanCharsPerFrame: number;
maxCharsInOneFrame: number;
charsPerFrameCv: number;
firstPaintFrames: number;
firstPaintChars: number;
maxFirstPaintCharsInOneFrame: number;
handoverGrowthFrames: number;
handoverGrowthChars: number;
maxHandoverGrowthCharsInOneFrame: number;
updateGapP50Ms: number;
updateGapP95Ms: number;
updateGapMaxMs: number;
}
/**
* Record text growth on every mounted assistant message once per frame.
*
* Message occurrence identity separates paced growth from first paint. Completed markdown
* blocks move into canonical timeline rows while a turn streams, remounting text
* that was already visible. Counting the new node's full length as growth would
* report that handover as a large reveal even though no text appeared on screen.
* First paints stay in the report as a separate diagnostic.
*
* Runs entirely in the page so the sampling clock is the same clock the reveal
* runs on. Waits for the text to start moving first, so a slow model's
* time-to-first-token is not counted as a stall.
*/
export async function sampleStreamFrames(
page: Page,
options: { sampleWindowMs: number; startTimeoutMs?: number },
): Promise<StreamFrameSample[]> {
const startTimeoutMs = options.startTimeoutMs ?? 60_000;
return await page.evaluate(
async ({ sampleWindowMs, startTimeoutMs: waitMs }) => {
const readLength = () => {
let total = 0;
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
total += node.textContent?.length ?? 0;
}
return total;
};
const waitStart = performance.now();
const base = readLength();
await new Promise<void>((resolve) => {
const poll = () => {
if (readLength() > base + 20 || performance.now() - waitStart > waitMs) {
resolve();
return;
}
requestAnimationFrame(poll);
};
requestAnimationFrame(poll);
});
let previousLengths = new Map<string, number>();
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
const key = node.getAttribute("data-reveal-key");
const length = Number(node.getAttribute("data-reveal-length"));
if (key !== null && Number.isFinite(length)) {
previousLengths.set(key, length);
}
}
const samples: Array<{
timestampMs: number;
growthChars: number;
firstPaintChars: number;
}> = [];
await new Promise<void>((resolve) => {
const start = performance.now();
const tick = (now: number) => {
const currentLengths = new Map<string, number>();
let growthChars = 0;
let firstPaintChars = 0;
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
const key = node.getAttribute("data-reveal-key");
const currentLength = Number(node.getAttribute("data-reveal-length"));
if (key === null || !Number.isFinite(currentLength)) {
continue;
}
const previousLength = previousLengths.get(key);
if (previousLength === undefined || currentLength < previousLength) {
firstPaintChars += currentLength;
} else {
const nodeGrowth = currentLength - previousLength;
growthChars += nodeGrowth;
}
currentLengths.set(key, currentLength);
}
samples.push({
timestampMs: now,
growthChars,
firstPaintChars,
});
previousLengths = currentLengths;
if (now - start >= sampleWindowMs) {
resolve();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
return samples;
},
{ sampleWindowMs: options.sampleWindowMs, startTimeoutMs },
);
}
export function computePercentile(samples: readonly number[], percentile: number): number {
if (samples.length === 0) {
return 0;
}
const sorted = [...samples].sort((a, b) => a - b);
return sorted[Math.max(0, Math.ceil((percentile / 100) * sorted.length) - 1)];
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/**
* Reduce frame samples to the report. Frames outside the streaming window are
* dropped: leading idle is time-to-first-token, and trailing idle means the turn
* ended mid-sample. First-paint frames are structural handovers. Existing blocks
* may also snap complete on those frames, so their growth is reported separately
* from the paced-growth CV.
*/
export function summarizeStreamSmoothness(
samples: readonly StreamFrameSample[],
sampleWindowMs: number,
): StreamSmoothnessReport {
const deltas: number[] = [];
const gapsMs: number[] = [];
let lastUpdateAtMs: number | null = null;
let firstGrowth = -1;
let lastGrowth = -1;
const handoverGrowths: number[] = [];
for (let index = 0; index < samples.length; index += 1) {
const sample = samples[index];
const isHandover = sample.firstPaintChars > 0;
const growth = isHandover ? 0 : sample.growthChars;
if (isHandover && sample.growthChars > 0) {
handoverGrowths.push(sample.growthChars);
lastUpdateAtMs = sample.timestampMs;
}
deltas.push(growth);
if (growth > 0) {
if (firstGrowth === -1) {
firstGrowth = deltas.length - 1;
}
lastGrowth = deltas.length - 1;
if (lastUpdateAtMs !== null) {
gapsMs.push(samples[index].timestampMs - lastUpdateAtMs);
}
lastUpdateAtMs = samples[index].timestampMs;
}
}
const active = firstGrowth === -1 ? [] : deltas.slice(firstGrowth, lastGrowth + 1);
const charsPainted = active.reduce((sum, delta) => sum + delta, 0);
const mean = active.length > 0 ? charsPainted / active.length : 0;
const variance =
active.length > 0
? active.reduce((sum, delta) => sum + (delta - mean) ** 2, 0) / active.length
: 0;
const firstPaints = samples.map((sample) => sample.firstPaintChars).filter((chars) => chars > 0);
return {
sampleWindowMs,
frames: active.length,
growthFrames: active.filter((delta) => delta > 0).length,
charsPainted,
meanCharsPerFrame: round2(mean),
maxCharsInOneFrame: active.length > 0 ? Math.max(...active) : 0,
charsPerFrameCv: mean === 0 ? Number.POSITIVE_INFINITY : round2(Math.sqrt(variance) / mean),
firstPaintFrames: firstPaints.length,
firstPaintChars: firstPaints.reduce((sum, chars) => sum + chars, 0),
maxFirstPaintCharsInOneFrame: firstPaints.length > 0 ? Math.max(...firstPaints) : 0,
handoverGrowthFrames: handoverGrowths.length,
handoverGrowthChars: handoverGrowths.reduce((sum, chars) => sum + chars, 0),
maxHandoverGrowthCharsInOneFrame: handoverGrowths.length > 0 ? Math.max(...handoverGrowths) : 0,
updateGapP50Ms: round2(computePercentile(gapsMs, 50)),
updateGapP95Ms: round2(computePercentile(gapsMs, 95)),
updateGapMaxMs: gapsMs.length > 0 ? round2(Math.max(...gapsMs)) : 0,
};
}
@@ -88,13 +88,16 @@ export async function ensureSidePanel(page: Page): Promise<Locator> {
*/
async function openSidePanelView(
page: Page,
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number },
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
): Promise<void> {
const panel = await ensureSidePanel(page);
const tab = panel.getByTestId(view.tabTestId);
if ((await tab.count()) === 0) {
await panel.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, view.menuTestId).first().click();
await panel.getByTestId("workspace-new-tab-button").click();
await page
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
.filter({ visible: true })
.click();
}
await selectWorkspaceTab(tab);
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
@@ -105,7 +108,7 @@ async function openSidePanelView(
export async function openChangesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff",
menuTestId: "workspace-new-tab-menu-changes",
launchItemId: "changes",
contentTestId: "working-diff-panel",
});
}
@@ -113,7 +116,7 @@ export async function openChangesPanel(page: Page): Promise<void> {
export async function openFilesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-files",
menuTestId: "workspace-new-tab-menu-files",
launchItemId: "files",
contentTestId: "file-explorer-tree-scroll",
});
}
@@ -127,7 +130,7 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
}
await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request",
menuTestId: "workspace-new-tab-menu-pull-request",
launchItemId: "pull-request",
contentTestId: "pr-pane",
timeout: 15_000,
});
@@ -137,14 +140,14 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({
await expect(visibleTestId(page, "workspace-new-tab-button").first()).toBeVisible({
timeout: 30_000,
});
}
/** Open the `+` menu in the tab row and pick "New agent". */
/** Open the pane-local `+` menu and pick Agent. */
export async function createAgentTabFromMenu(page: Page): Promise<void> {
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
+24 -2
View File
@@ -1,5 +1,11 @@
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
const stableIosBuildSlot = 999;
const FDROID_ABI_VERSION_CODE_SUFFIXES = {
"armeabi-v7a": 1,
"arm64-v8a": 2,
x86: 3,
x86_64: 4,
};
function getNativeReleaseVersion(version) {
const match = versionPattern.exec(version);
@@ -21,7 +27,11 @@ function getNativeReleaseVersion(version) {
}
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
if (
!Number.isSafeInteger(versionCode) ||
versionCode <= 0 ||
versionCode * 10 + 9 > 2_100_000_000
) {
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
}
@@ -38,4 +48,16 @@ function getNativeReleaseVersion(version) {
};
}
module.exports = { getNativeReleaseVersion };
function getFdroidVersionCodes(version) {
const { androidVersionCode } = getNativeReleaseVersion(version);
return Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES).map(([abi, suffix]) => ({
abi,
versionCode: androidVersionCode * 10 + suffix,
}));
}
module.exports = {
FDROID_ABI_VERSION_CODE_SUFFIXES,
getFdroidVersionCodes,
getNativeReleaseVersion,
};
+20 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
const { getNativeReleaseVersion } = require("./native-release-version");
const {
FDROID_ABI_VERSION_CODE_SUFFIXES,
getFdroidVersionCodes,
getNativeReleaseVersion,
} = require("./native-release-version");
describe("native release version", () => {
it("reserves the final iOS build slot for a stable release", () => {
@@ -24,4 +28,19 @@ describe("native release version", () => {
"iOS beta number must be between 1 and 998",
);
});
it("derives one F-Droid version code per published ABI", () => {
expect(FDROID_ABI_VERSION_CODE_SUFFIXES).toEqual({
"armeabi-v7a": 1,
"arm64-v8a": 2,
x86: 3,
x86_64: 4,
});
expect(getFdroidVersionCodes("0.5.0")).toEqual([
{ abi: "armeabi-v7a", versionCode: 50001 },
{ abi: "arm64-v8a", versionCode: 50002 },
{ abi: "x86", versionCode: 50003 },
{ abi: "x86_64", versionCode: 50004 },
]);
});
});
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -23,6 +23,7 @@
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project=browser",
"test:e2e:diff-performance": "cross-env PASEO_DIFF_PERF_E2E=1 playwright test --project=browser e2e/browser/diff-performance.spec.ts",
"test:e2e:stream-smoothness": "cross-env PASEO_AGENT_STREAM_PERF_E2E=1 playwright test --project=browser e2e/browser/agent-stream-smoothness.spec.ts",
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
"test:e2e:relay-deployment": "npm --prefix ../.. run build:server && npm --prefix ../.. run build:daemon-web-ui && cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=relay-deployment e2e/browser/relay-deployment-reconnect.real.spec.ts",
"test:e2e:ui": "playwright test --ui",
@@ -95,6 +96,7 @@
"expo-router": "~6.0.13",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-sqlite": "~16.0.10",
"expo-system-ui": "~6.0.7",
"fast-deep-equal": "^3.1.3",
"htmlparser2": "^12.0.0",
@@ -136,6 +138,7 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/markdown-it": "^14.1.2",
"@types/node": "~22.19.0",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/turndown": "^5.0.6",
@@ -149,6 +152,7 @@
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"expo-gradle-jvmargs": "^1.1.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^20.0.3",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
@@ -1,6 +1,7 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { withAppBuildGradle, withDangerousMod, withSettingsGradle } = require("expo/config-plugins");
const { FDROID_ABI_VERSION_CODE_SUFFIXES } = require("../native-release-version");
const EXCLUDED_ANDROID_MODULES = [
"expo-camera",
@@ -11,12 +12,15 @@ const EXCLUDED_ANDROID_MODULES = [
"expo-dev-menu-interface",
];
// Generated from the shared suffix table so the Groovy literal can never drift
// from the version codes the F-Droid changelog filenames are derived from.
const FDROID_ABI_VERSION_CODE_ENTRIES = Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES)
.map(([abi, suffix]) => ` "${abi}": ${suffix},`)
.join("\n");
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
def paseoAbiVersionCodes = [
"armeabi-v7a": 1,
"arm64-v8a": 2,
"x86": 3,
"x86_64": 4,
${FDROID_ABI_VERSION_CODE_ENTRIES}
]
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
.toString()
@@ -1,7 +1,6 @@
import { withUnistyles } from "react-native-unistyles";
import {
BookOpen,
Bot,
Boxes,
Brain,
Bug,
@@ -27,6 +26,7 @@ import {
Server,
Shield,
Sparkles,
Star,
Terminal,
TestTube,
Wrench,
@@ -42,7 +42,7 @@ import {
} from "./profile-appearance";
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
const ThemedDefaultIcon = withUnistyles(Bot);
const ThemedDefaultIcon = withUnistyles(Star);
/**
* `withUnistyles` has to wrap each icon once at module scope, so the registry
@@ -10,7 +10,7 @@ export interface BottomAnchorRouteRequest {
}
export interface BottomAnchorLocalRequest {
reason: "jump-to-bottom" | "message-sent";
reason: "jump-to-bottom" | "message-sent" | "rewind";
agentId: string;
}
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { layoutStream } from "./layout";
import type { StreamStrategy } from "./strategy";
function strategyFor(platform: "web" | "native"): StreamStrategy {
const inverted = platform === "native";
const neighborStep = inverted ? 1 : -1;
const getHistoryBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? 0 : items.length - 1;
};
const getLiveHeadBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? items.length - 1 : 0;
};
return {
orderTail: (items) => (inverted ? [...items].toReversed() : items),
orderHead: (items) => (inverted ? [...items].toReversed() : items),
getNeighborIndex: (index, relation) =>
relation === "above" ? index + neighborStep : index - neighborStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep;
return items[neighborIndex];
},
getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex,
getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex,
getLatestItemIndex: getHistoryBoundaryIndex,
getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"),
} as StreamStrategy;
}
function userMessage(id: string): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id,
text: id,
timestamp: new Date("2026-08-22T10:00:00.000Z"),
};
}
function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: new Date("2026-08-22T10:00:01.000Z"),
};
}
function assistantBlock(
id: string,
blockIndex: number,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
...assistantMessage(id),
blockGroupId: "response",
blockIndex,
};
}
function timingFor(assistantId: string): Map<string, TurnTiming> {
return new Map([
[
assistantId,
{
startedAt: new Date("2026-08-22T10:00:00.000Z"),
completedAt: new Date("2026-08-22T10:00:02.000Z"),
durationMs: 2_000,
},
],
]);
}
function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) {
const strategy = strategyFor(input.platform);
const assistant = assistantMessage("assistant");
return {
assistant,
layout: layoutStream({
strategy,
isTurnActive: input.isTurnActive,
history: strategy.orderTail([userMessage("user"), assistant]),
liveHead: [],
timingByAssistantId: timingFor(assistant.id),
}),
};
}
describe("turn footer spacing", () => {
it.each(["web", "native"] as const)(
"compacts the last assistant against the completed footer on %s",
(platform) => {
const { assistant, layout } = layoutFor({ platform, isTurnActive: false });
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
},
);
it.each(["web", "native"] as const)(
"keeps the same compact edge while the footer is running on %s",
(platform) => {
const { assistant, layout } = layoutFor({ platform, isTurnActive: true });
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
},
);
it.each([
["web", false],
["web", true],
["native", false],
["native", true],
] as const)(
"compacts both edges of the final split paragraph on %s when active=%s",
(platform, isTurnActive) => {
const strategy = strategyFor(platform);
const first = assistantBlock("first", 0);
const last = assistantBlock("last", 1);
const layout = layoutStream({
strategy,
isTurnActive,
history: strategy.orderTail([userMessage("user"), first]),
liveHead: strategy.orderHead([last]),
timingByAssistantId: timingFor(last.id),
});
const firstLayout = layout.history.find((item) => item.item.id === first.id);
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
},
);
});
+1 -1
View File
@@ -330,7 +330,7 @@ describe("layoutStream", () => {
});
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
});
it.each(["web", "android"] as const)(
+11 -5
View File
@@ -49,6 +49,7 @@ interface LayoutSegmentInput {
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder;
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
@@ -249,11 +250,6 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
@@ -265,6 +261,12 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
return {
item,
@@ -294,6 +296,7 @@ const historyLayoutCache = new WeakMap<StreamItem[], Map<string, StreamLayoutIte
export function layoutStream(input: StreamLayoutInput): StreamLayout {
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
const hasAuxiliaryFooter = input.isTurnActive || auxiliaryTurnFooter !== null;
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
const historyBoundaryItem =
@@ -313,6 +316,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
liveHeadBoundaryItem?.kind ?? "null",
liveHeadBoundaryItem?.turnId ?? "null",
auxiliaryTurnFooter?.itemId ?? "null",
hasAuxiliaryFooter ? "footer" : "no-footer",
].join(":");
let byKey = historyLayoutCache.get(input.history);
if (!byKey) {
@@ -328,6 +332,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
@@ -347,6 +352,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
@@ -74,6 +74,18 @@ describe("getAssistantBlockSpacing", () => {
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
});
it("compacts the bottom edge when the turn footer follows the assistant block", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(
getAssistantBlockSpacing({
item: a,
aboveItem: null,
belowItem: null,
hasFooterBelow: true,
}),
).toBe("compactBottom");
});
it("returns compactTop when the item above is in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
+7 -1
View File
@@ -17,12 +17,15 @@ export function getAssistantBlockSpacing(params: {
item: StreamItem;
aboveItem: StreamItem | null | undefined;
belowItem: StreamItem | null | undefined;
hasFooterBelow?: boolean;
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
if (params.item.kind !== "assistant_message") {
return "default";
}
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
const compactBottom =
params.hasFooterBelow ||
isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
if (compactTop && compactBottom) return "compactBoth";
if (compactTop) return "compactTop";
if (compactBottom) return "compactBottom";
@@ -44,6 +47,9 @@ export function getGapBetweenStreamItems(
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
return 0;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
+12 -6
View File
@@ -15,7 +15,10 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
import { estimateStreamItemHeight } from "./web-virtualization";
import {
estimateStreamItemHeight,
shouldAdjustScrollForVirtualRowResize,
} from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
import {
@@ -370,14 +373,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
overscan: 8,
});
useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
if (historyStartPrependAnchorActiveRef.current) {
return false;
}
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
return shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current,
rowStart: item.start,
scrollOffset,
remainingDistanceFromBottom: remainingDistance,
bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
});
};
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
@@ -0,0 +1,277 @@
import { describe, expect, it } from "vitest";
import {
advanceTextReveal,
beginTextReveal,
clampToSafeRevealBoundary,
completeTextReveal,
computeRevealStep,
nextTextRevealFrame,
isTextRevealSettled,
retargetTextReveal,
TEXT_REVEAL_FRAME_INTERVAL_MS,
TEXT_REVEAL_HORIZON_MS,
visibleRevealedText,
} from "./text-reveal";
describe("computeRevealStep", () => {
it("reveals nothing when there is no backlog", () => {
expect(computeRevealStep({ backlog: 0, elapsedMs: 16 })).toBe(0);
expect(computeRevealStep({ backlog: -5, elapsedMs: 16 })).toBe(0);
});
it("drains the backlog over the horizon", () => {
const step = computeRevealStep({ backlog: 300, elapsedMs: TEXT_REVEAL_HORIZON_MS / 2 });
expect(step).toBe(150);
});
it("reveals the whole backlog once a frame covers the horizon", () => {
expect(computeRevealStep({ backlog: 42, elapsedMs: TEXT_REVEAL_HORIZON_MS })).toBe(42);
expect(computeRevealStep({ backlog: 42, elapsedMs: TEXT_REVEAL_HORIZON_MS * 10 })).toBe(42);
});
it("scales the rate with the backlog so a faster model reveals faster", () => {
const slow = computeRevealStep({ backlog: 20, elapsedMs: 16 });
const fast = computeRevealStep({ backlog: 200, elapsedMs: 16 });
expect(fast).toBeGreaterThan(slow);
});
it("always advances at least one character so the tail finishes", () => {
expect(computeRevealStep({ backlog: 1, elapsedMs: 1 })).toBe(1);
expect(computeRevealStep({ backlog: 2, elapsedMs: 0.01 })).toBe(1);
});
it("never overshoots the backlog", () => {
for (const backlog of [1, 3, 7, 50]) {
expect(computeRevealStep({ backlog, elapsedMs: 120 })).toBeLessThanOrEqual(backlog);
}
});
it("reveals nothing when no time has passed", () => {
expect(computeRevealStep({ backlog: 100, elapsedMs: 0 })).toBe(0);
expect(computeRevealStep({ backlog: 100, elapsedMs: -16 })).toBe(0);
});
it("converges within a bounded number of frames at 60Hz", () => {
let remaining = 500;
let frames = 0;
while (remaining > 0) {
remaining -= computeRevealStep({ backlog: remaining, elapsedMs: 16 });
frames += 1;
expect(frames).toBeLessThan(120);
}
expect(remaining).toBe(0);
});
it("keeps per-frame steps smooth for a bursty arrival pattern", () => {
// A lumpy arrival sequence: the daemon's coalescing window carries wildly
// different character counts depending on model throughput.
const arrivals = [4, 180, 6, 240, 2, 90, 300, 8];
const steps: number[] = [];
let backlog = 0;
for (const arrival of arrivals) {
backlog += arrival;
// ~60ms of frames between coalesced flushes.
for (let frame = 0; frame < 4; frame += 1) {
const step = computeRevealStep({ backlog, elapsedMs: 16 });
backlog -= step;
steps.push(step);
}
}
// Painting arrivals directly would swing from 2 to 300 characters in one
// paint. Pacing keeps the largest single paint far below the largest arrival.
expect(Math.max(...steps)).toBeLessThan(Math.max(...arrivals) / 2);
expect(Math.min(...steps)).toBeGreaterThan(0);
});
});
describe("clampToSafeRevealBoundary", () => {
it("leaves boundaries in plain text alone", () => {
expect(clampToSafeRevealBoundary("hello world", 5)).toBe(5);
});
it("clamps the ends", () => {
expect(clampToSafeRevealBoundary("hello", -3)).toBe(0);
expect(clampToSafeRevealBoundary("hello", 99)).toBe(5);
});
it("never splits a surrogate pair", () => {
// "a😀b" — the emoji occupies indices 1 and 2.
const text = "a😀b";
expect(clampToSafeRevealBoundary(text, 2)).toBe(1);
expect(clampToSafeRevealBoundary(text, 3)).toBe(3);
});
it("does not strand a combining mark", () => {
// "e" + combining acute; cutting at 1 would show a bare "e" then reflow.
const text = `éx`;
expect(clampToSafeRevealBoundary(text, 1)).toBe(0);
expect(clampToSafeRevealBoundary(text, 2)).toBe(2);
});
it("does not split an emoji ZWJ sequence", () => {
// Family emoji: person ZWJ person ZWJ child.
const family = "\u{1F468}‍\u{1F469}‍\u{1F467}";
const text = `${family}!`;
for (let index = 1; index < family.length; index += 1) {
expect(clampToSafeRevealBoundary(text, index)).toBe(0);
}
expect(clampToSafeRevealBoundary(text, family.length)).toBe(family.length);
});
it("does not split a skin tone modifier from its base", () => {
const text = "\u{1F44D}\u{1F3FD} nice";
expect(clampToSafeRevealBoundary(text, 2)).toBe(0);
expect(clampToSafeRevealBoundary(text, 4)).toBe(4);
});
it("does not strand a variation selector", () => {
const text = "❤️ done";
expect(clampToSafeRevealBoundary(text, 1)).toBe(0);
expect(clampToSafeRevealBoundary(text, 2)).toBe(2);
});
it("does not split extended grapheme clusters", () => {
for (const { text, boundaries } of [
{ text: "a🇺🇸b", boundaries: [0, 1, 5, 6] },
{ text: "a가b", boundaries: [0, 1, 3, 4] },
{ text: "aकःb", boundaries: [0, 1, 3, 4] },
]) {
for (let index = 1; index < text.length; index += 1) {
const expected = boundaries.toReversed().find((boundary) => boundary <= index);
expect(clampToSafeRevealBoundary(text, index)).toBe(expected);
}
}
});
});
describe("nextTextRevealFrame", () => {
it("caps a high-refresh frame clock at 60Hz", () => {
expect(nextTextRevealFrame(null, 0)).toEqual({
elapsedMs: TEXT_REVEAL_FRAME_INTERVAL_MS,
frameAtMs: 0,
});
expect(nextTextRevealFrame(0, 8)).toBeNull();
expect(nextTextRevealFrame(0, 17)).toEqual({
elapsedMs: 17,
frameAtMs: expect.closeTo(TEXT_REVEAL_FRAME_INTERVAL_MS, 5),
});
});
});
describe("text reveal state", () => {
it("reveals the first text it sees in full", () => {
const state = beginTextReveal("already here");
expect(visibleRevealedText(state)).toBe("already here");
expect(isTextRevealSettled(state)).toBe(true);
});
it("paces growth instead of painting it whole", () => {
let state = beginTextReveal("a");
state = retargetTextReveal(state, `a${"b".repeat(300)}`);
expect(visibleRevealedText(state)).toBe("a");
state = advanceTextReveal(state, 16);
const shown = visibleRevealedText(state);
expect(shown.length).toBeGreaterThan(1);
expect(shown.length).toBeLessThan(301);
});
it("catches up to the full text across frames", () => {
const full = `a${"b".repeat(200)}`;
let state = retargetTextReveal(beginTextReveal("a"), full);
for (let frame = 0; frame < 60 && !isTextRevealSettled(state); frame += 1) {
state = advanceTextReveal(state, 16);
}
expect(isTextRevealSettled(state)).toBe(true);
expect(visibleRevealedText(state)).toBe(full);
});
it("releases everything when the turn completes", () => {
const full = `a${"b".repeat(500)}`;
let state = advanceTextReveal(retargetTextReveal(beginTextReveal("a"), full), 16);
expect(visibleRevealedText(state)).not.toBe(full);
state = completeTextReveal(state);
expect(visibleRevealedText(state)).toBe(full);
});
it("only ever paints a prefix of the real text", () => {
const full = "the quick brown fox jumps over the lazy dog";
let state = retargetTextReveal(beginTextReveal("the "), full);
for (let frame = 0; frame < 20; frame += 1) {
expect(full.startsWith(visibleRevealedText(state))).toBe(true);
state = advanceTextReveal(state, 16);
}
});
it("clamps when the slot switches to a shorter message", () => {
const state = retargetTextReveal(beginTextReveal("a long streaming message"), "short");
expect(visibleRevealedText(state)).toBe("short");
});
it("never paints a lone surrogate while streaming emoji", () => {
const full = `progress ${"\u{1F680}".repeat(40)} done`;
let state = retargetTextReveal(beginTextReveal("progress "), full);
for (let frame = 0; frame < 200 && !isTextRevealSettled(state); frame += 1) {
const shown = visibleRevealedText(state);
// A stranded surrogate would make the painted prefix un-decodable.
expect(shown).toBe(Array.from(shown).join(""));
expect(full.startsWith(shown)).toBe(true);
state = advanceTextReveal(state, 16);
}
expect(visibleRevealedText(completeTextReveal(state))).toBe(full);
});
it("a long cluster cannot stall the reveal", () => {
// The rendered boundary backs up over this cluster, but the counter does not,
// so the reveal still finishes.
const full = `x${"́".repeat(40)}y`;
let state = retargetTextReveal(beginTextReveal("x"), full);
for (let frame = 0; frame < 200 && !isTextRevealSettled(state); frame += 1) {
state = advanceTextReveal(state, 16);
}
expect(isTextRevealSettled(state)).toBe(true);
expect(visibleRevealedText(state)).toBe(full);
});
});
describe("trailing fragments from arrival boundaries", () => {
it("holds back half a flag while more text is coming", () => {
// The daemon's coalescing window can end a delta mid-cluster, so a caught-up
// reveal can be handed a single regional indicator.
const halfFlag = "flags: \u{1F1EC}";
const state = beginTextReveal(halfFlag);
expect(visibleRevealedText(state, { streaming: true })).toBe("flags: ");
// Once the turn ends nothing more is coming, so paint what there is.
expect(visibleRevealedText(state, { streaming: false })).toBe(halfFlag);
});
it("paints the flag once its second half arrives", () => {
const whole = "flags: \u{1F1EC}\u{1F1E7}";
expect(visibleRevealedText(beginTextReveal(whole), { streaming: true })).toBe(whole);
});
it("keeps an even run of regional indicators intact", () => {
const twoFlags = "\u{1F1EC}\u{1F1E7}\u{1F1EB}\u{1F1F7}";
expect(visibleRevealedText(beginTextReveal(twoFlags), { streaming: true })).toBe(twoFlags);
});
it("holds back a dangling joiner", () => {
expect(
visibleRevealedText(beginTextReveal("family: \u{1F468}\u200D"), { streaming: true }),
).toBe("family: \u{1F468}");
});
it("holds back a dangling high surrogate", () => {
const loneHigh = `broken: ${String.fromCharCode(0xd83d)}`;
expect(visibleRevealedText(beginTextReveal(loneHigh), { streaming: true })).toBe("broken: ");
});
it("leaves ordinary trailing text alone", () => {
const prose = "just some prose";
expect(visibleRevealedText(beginTextReveal(prose), { streaming: true })).toBe(prose);
});
});
@@ -0,0 +1,240 @@
/**
* Paced reveal for streaming assistant text.
*
* Deltas arrive lumpy: the daemon coalesces one message per 60ms window, carrying
* however many characters the model happened to produce in that window. Painting
* each delta as it lands makes the size of those lumps visible, which is what
* reads as jagged. So arrival changes the *target* and the reveal rate is derived
* from the backlog instead — a burst makes the text catch up faster, it doesn't
* make it jump.
*
* The store keeps the full text. Only the rendered slice is paced, so copy,
* selection, the chat outline, and scroll geometry all stay consistent with what
* is on screen.
*
* Everything here is pure. The only thing the React hook adds is a frame clock,
* which keeps the policy testable without a renderer.
*/
// Backlog is drained over this horizon. Shorter feels more like the raw arrival
// pattern; longer adds lag the user can notice at the end of a turn.
export const TEXT_REVEAL_HORIZON_MS = 150;
/** Reveal at most once per 60Hz frame, even on high-refresh displays. */
export const TEXT_REVEAL_FRAME_INTERVAL_MS = 1000 / 60;
// A frame's elapsed time is clamped to this before it is used, so a long stall
// (backgrounded tab, blocked main thread) doesn't produce a wild step from one
// enormous delta.
const MAX_ELAPSED_MS = 250;
/**
* Characters to reveal on this frame. Proportional to the backlog, so the reveal
* accelerates when the model runs ahead and settles when it doesn't, with a
* one-character floor so the tail always finishes.
*/
export function computeRevealStep(input: {
backlog: number;
elapsedMs: number;
horizonMs?: number;
}): number {
const { backlog } = input;
if (backlog <= 0) {
return 0;
}
const horizonMs = input.horizonMs ?? TEXT_REVEAL_HORIZON_MS;
if (horizonMs <= 0) {
return backlog;
}
const elapsedMs = Math.min(Math.max(input.elapsedMs, 0), MAX_ELAPSED_MS);
if (elapsedMs <= 0) {
return 0;
}
if (elapsedMs >= horizonMs) {
return backlog;
}
const step = Math.ceil((backlog * elapsedMs) / horizonMs);
return Math.min(backlog, Math.max(1, step));
}
const ZERO_WIDTH_JOINER = 0x200d;
const graphemeSegmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
/**
* Pacing needs a conformant grapheme segmenter. Older runtimes paint each
* arrival whole instead of risking a transient broken glyph.
*/
export function isTextRevealPacingSupported(): boolean {
return graphemeSegmenter !== null;
}
/**
* Pull a cut index back to somewhere it is safe to slice.
*
* A raw `slice` at an arbitrary index can split a grapheme cluster, which makes
* one visible glyph briefly render as separate parts while the text is streaming.
*
* This only moves the *rendered* boundary. The reveal counter stays monotonic, so
* a long cluster can never stall the reveal — the next frame steps past it.
*/
export function clampToSafeRevealBoundary(text: string, index: number): number {
if (index <= 0) {
return 0;
}
if (index >= text.length) {
return text.length;
}
const segment = graphemeSegmenter?.segment(text).containing(index);
return segment?.index ?? 0;
}
export interface TextRevealFrame {
elapsedMs: number;
frameAtMs: number;
}
/**
* Keep reveal commits at 60Hz while carrying timing remainder forward so a
* high-refresh display does not make the reveal render at hardware frame rate.
*/
export function nextTextRevealFrame(
previousFrameAtMs: number | null,
timestampMs: number,
): TextRevealFrame | null {
const elapsedMs =
previousFrameAtMs === null ? TEXT_REVEAL_FRAME_INTERVAL_MS : timestampMs - previousFrameAtMs;
if (elapsedMs < TEXT_REVEAL_FRAME_INTERVAL_MS) {
return null;
}
return {
elapsedMs,
frameAtMs: timestampMs - (elapsedMs % TEXT_REVEAL_FRAME_INTERVAL_MS),
};
}
export interface TextRevealState {
/** The full text as the store knows it. */
readonly target: string;
/** How much of it has been released. Monotonic within a message. */
readonly revealed: number;
}
/**
* First sight of a text is revealed whole. Only growth is paced, which is what
* makes history hydration, timeline replay, a virtualized row remounting on
* scroll, and an already-finished message all render complete on first paint
* without a special case for each.
*/
export function beginTextReveal(text: string): TextRevealState {
return { target: text, revealed: text.length };
}
/** Point the reveal at newly arrived text without changing how much is shown. */
export function retargetTextReveal(state: TextRevealState, text: string): TextRevealState {
if (state.target === text) {
return state;
}
// A shorter string means this slot is showing a different message than the one
// the reveal position belongs to.
return { target: text, revealed: Math.min(state.revealed, text.length) };
}
/** Release one frame's worth of characters. */
export function advanceTextReveal(
state: TextRevealState,
elapsedMs: number,
horizonMs?: number,
): TextRevealState {
const step = computeRevealStep({
backlog: state.target.length - state.revealed,
elapsedMs,
...(horizonMs !== undefined ? { horizonMs } : {}),
});
if (step <= 0) {
return state;
}
return { target: state.target, revealed: Math.min(state.target.length, state.revealed + step) };
}
/** Release everything, for when the turn ends and nothing may be held back. */
export function completeTextReveal(state: TextRevealState): TextRevealState {
if (state.revealed >= state.target.length) {
return state;
}
return { target: state.target, revealed: state.target.length };
}
export function isTextRevealSettled(state: TextRevealState): boolean {
return state.revealed >= state.target.length;
}
/**
* Hold back a trailing fragment that cannot stand on its own.
*
* The reveal boundary is not the only place a cluster gets cut. The daemon's
* coalescing window ends a delta wherever the window closes, so a caught-up
* reveal can be handed half a flag and paint it — measured on a mixed-script
* stream as frames ending in a single regional indicator. Only fragments that
* are certainly waiting on more input are withheld: a dangling high surrogate, a
* trailing joiner, or an odd regional indicator.
*/
function trimIncompleteTrailingCluster(text: string): string {
if (text.length === 0) {
return text;
}
const lastUnit = text.charCodeAt(text.length - 1);
if (lastUnit >= 0xd800 && lastUnit <= 0xdbff) {
return text.slice(0, -1);
}
if (lastUnit === ZERO_WIDTH_JOINER) {
return text.slice(0, -1);
}
const trailingRegionalIndicators = countTrailingRegionalIndicators(text);
if (trailingRegionalIndicators % 2 === 1) {
return text.slice(0, -2);
}
return text;
}
/** Number of regional indicators at the end of `text`. A flag is a pair. */
function countTrailingRegionalIndicators(text: string): number {
let count = 0;
let cursor = text.length;
while (cursor >= 2) {
const codePoint = text.codePointAt(cursor - 2);
if (codePoint === undefined || codePoint < 0x1f1e6 || codePoint > 0x1f1ff) {
break;
}
count += 1;
cursor -= 2;
}
return count;
}
/**
* What should actually be painted this frame.
*
* `streaming` says whether more text is still expected. While it is, a trailing
* fragment waits for the rest of its cluster; once the turn ends everything
* received is painted, because nothing more is coming.
*/
export function visibleRevealedText(
state: TextRevealState,
options?: { streaming?: boolean },
): string {
if (state.revealed >= state.target.length) {
return options?.streaming ? trimIncompleteTrailingCluster(state.target) : state.target;
}
return state.target.slice(0, clampToSafeRevealBoundary(state.target, state.revealed));
}
@@ -1,108 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-native", () => ({
Platform: {
OS: "web",
select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default,
},
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? (factory as (theme: Record<string, unknown>) => unknown)({
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
colors: {
foregroundMuted: "#aaa",
palette: { amber: { 500: "#f0b429", 700: "#b7791f" } },
},
})
: factory,
},
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
withUnistyles: <T,>(component: T) => component,
}));
vi.mock("@/components/message", () => ({
AssistantTurnFooter: () => null,
LiveElapsed: () => <span data-testid="running-turn-timestamp" />,
STREAM_METADATA_FONT_SIZE: 11,
}));
vi.mock("@/components/assistant-fork-menu", () => ({
AssistantForkMenu: () => <button data-testid="running-turn-fork" type="button" />,
}));
vi.mock("@/components/synced-loader", () => ({
SyncedLoader: () => <span data-testid="running-turn-loader" />,
}));
vi.mock("@/components/retained-panel", () => ({
useRetainedPanelActive: () => true,
}));
import { TurnFooter } from "./turn-footer";
const unusedRunningTurnStrategy = null as unknown as React.ComponentProps<
typeof TurnFooter
>["strategy"];
describe("TurnFooter", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
});
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
it("places the running-turn fork between the loader and timestamp", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<TurnFooter
isRunning
inFlightTurnStartedAt={new Date("2026-08-01T10:00:00.000Z")}
host={null}
strategy={unusedRunningTurnStrategy}
supportsTimelineCursor
onForkInFlightTurn={vi.fn()}
/>,
);
});
const footer = container.querySelector('[data-testid="turn-working-indicator"]');
const controls = Array.from(footer?.querySelectorAll("[data-testid]") ?? []).map((node) =>
node.getAttribute("data-testid"),
);
expect(controls).toEqual([
"running-turn-loader",
"running-turn-fork",
"running-turn-timestamp",
]);
});
});
@@ -23,7 +23,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[6];
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -220,7 +220,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
},
turnFooterRow: {
marginTop: theme.spacing[4],
marginTop: theme.spacing[2] + 5,
},
turnFooterSlot: {
flexDirection: "row",
+52 -7
View File
@@ -51,6 +51,7 @@ import type {
} from "@getpaseo/protocol/agent-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import { useSettings } from "@/hooks/use-settings";
@@ -260,6 +261,8 @@ export interface AgentStreamViewProps {
isAuthoritativeHistoryReady?: boolean;
/** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayTailClearance?: number;
/** Bottom offset required for controls floating above that overlay. */
bottomOverlayControlClearance?: number;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
readOnly?: boolean;
@@ -295,6 +298,10 @@ function useRetainedValue<T>(value: T, active: boolean): T {
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
function resolveBottomOverlayControlOffset(clearance: number | undefined): number {
return Math.max(16, clearance ?? 0);
}
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
@@ -309,6 +316,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
onOpenWorkspaceFile,
readOnly = false,
@@ -710,15 +718,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderThoughtItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
return (
<ToolCallSlot
<ThoughtSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
text={item.text}
status={item.status}
isLastInSequence={layoutItem.isLastInToolSequence}
defaultExpanded={autoExpandReasoning}
forceInline={autoExpandReasoning}
/>
);
},
@@ -934,6 +940,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const scrollToBottomContainerStyle = useMemo(
() => [
stylesheet.scrollToBottomContainer,
{ bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) },
],
[bottomOverlayControlClearance],
);
const listEmptyComponent = useMemo(
() =>
renderListEmptyComponent({
@@ -1065,7 +1078,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onJumpToPrompt={chatOutline.jumpToPrompt}
/>
{(!isNearBottom || isTimelineDetached) && (
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
style={stylesheet.scrollToBottomButton}
@@ -1216,6 +1229,39 @@ interface ToolCallSlotProps extends Omit<
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
}
interface ThoughtSlotProps {
itemId: string;
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
text: string;
status: Extract<StreamItem, { kind: "thought" }>["status"];
isLastInSequence: boolean;
defaultExpanded: boolean;
}
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
function ThoughtSlot({
itemId,
onInlineDetailsExpandedChangeByItemId,
text,
status,
isLastInSequence,
defaultExpanded,
}: ThoughtSlotProps) {
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
return (
<ToolCallSlot
itemId={itemId}
onInlineDetailsExpandedChangeByItemId={onInlineDetailsExpandedChangeByItemId}
toolName="thinking"
args={revealedText}
status={status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
defaultExpanded={defaultExpanded}
forceInline={defaultExpanded}
/>
);
}
function ToolCallSlot({
itemId,
onInlineDetailsExpandedChangeByItemId,
@@ -1574,7 +1620,6 @@ const stylesheet = StyleSheet.create((theme) => ({
},
scrollToBottomContainer: {
position: "absolute",
bottom: 16,
left: 0,
right: 0,
alignItems: "center",
@@ -11,6 +11,7 @@ import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
shouldAdjustScrollForVirtualRowResize,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./web-virtualization";
@@ -168,6 +169,55 @@ describe("estimateStreamItemHeight", () => {
});
});
describe("virtual row resize anchoring", () => {
it("does not move the viewport when a visible row expands while detached", () => {
expect(
shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: false,
rowStart: 1200,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
}),
).toBe(false);
});
it("keeps the reading position when a row above the viewport changes size", () => {
expect(
shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: false,
rowStart: 800,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
}),
).toBe(true);
});
it("leaves history prepend and bottom following to their existing anchors", () => {
const baseInput = {
rowStart: 800,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
};
expect(
shouldAdjustScrollForVirtualRowResize({
...baseInput,
isHistoryStartPrependActive: true,
}),
).toBe(false);
expect(
shouldAdjustScrollForVirtualRowResize({
...baseInput,
isHistoryStartPrependActive: false,
remainingDistanceFromBottom: 64,
}),
).toBe(false);
});
});
describe("web virtualization test overrides", () => {
it("uses defaults unless explicit positive integer overrides are present", () => {
const globalWithOverrides = globalThis as typeof globalThis & {
@@ -10,6 +10,21 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
export function shouldAdjustScrollForVirtualRowResize(input: {
isHistoryStartPrependActive: boolean;
rowStart: number;
scrollOffset: number;
remainingDistanceFromBottom: number;
bottomThreshold: number;
}): boolean {
if (input.isHistoryStartPrependActive) {
return false;
}
return (
input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset
);
}
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
};
+10 -1
View File
@@ -953,9 +953,18 @@ function RootProviders({ children }: { children: ReactNode }) {
);
}
function recordUserActivity(): void {
getHostRuntimeStore().recordUserActivity();
}
function RootAppTree() {
return (
<GestureHandlerRootView style={flexStyle}>
<GestureHandlerRootView
style={flexStyle}
onTouchStart={recordUserActivity}
onTouchEnd={recordUserActivity}
onTouchCancel={recordUserActivity}
>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
+3 -1
View File
@@ -110,7 +110,9 @@ export type UserComposerAttachment =
| PluginResourceComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
// COMPAT(githubAttachmentKinds): legacy persisted attachment kinds retained
// when forge-neutral kinds shipped in v0.2.0-beta.1. Remove after 2027-01-17
// once supported floors are >= v0.2.0 and old drafts no longer need them.
| { kind: "github_issue"; item: ForgeSearchItem }
| {
kind: "github_pr";
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
newBrowser: "New browser",
splitRight: "Split pane right",
splitDown: "Split pane down",
changes: "Changes",
files: "Files",
pullRequest: "Pull request",
openPanel: (name, placement) => `Open ${name} ${placement}`,
previousTab: "Previous tab",
nextTab: "Next tab",
closeCurrentTab: "Close current tab",
renameTab: "Rename",
reloadAgent: "Reload agent",
copyResumeCommand: "Copy resume command",
copyAgentId: "Copy agent id",
copyTerminalId: "Copy terminal id",
copyFilePath: "Copy file path",
closeTabsLeft: "Close tabs left",
closeTabsRight: "Close tabs right",
closeOtherTabs: "Close other tabs",
focusPaneLeft: "Focus pane left",
focusPaneRight: "Focus pane right",
focusPaneUp: "Focus pane up",
focusPaneDown: "Focus pane down",
moveTabLeft: "Move tab left",
moveTabRight: "Move tab right",
moveTabUp: "Move tab up",
moveTabDown: "Move tab down",
closePane: "Close pane",
togglePaneMaximization: "Toggle pane maximization",
toggleFocusMode: "Toggle focus mode",
toggleSidePanel: "Toggle side panel",
},
icons: {},
shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
},
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
{ id: "tab:new-agent", rank: 0, visibility: "always" },
{ id: "git:commit", rank: 1, visibility: "always" },
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
{ id: "tab:new-browser", rank: 3, visibility: "query" },
{ id: "pane:split-right", rank: 4, visibility: "query" },
{ id: "pane:split-down", rank: 5, visibility: "query" },
expect(
contributions
.filter((item) =>
[
"tab:new-agent",
"git:commit",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
].includes(item.id),
)
.map(({ id, visibility }) => ({ id, visibility })),
).toEqual([
{ id: "tab:new-agent", visibility: "always" },
{ id: "git:commit", visibility: "always" },
{ id: "tab:new-terminal", visibility: "query" },
{ id: "tab:new-browser", visibility: "query" },
{ id: "pane:split-right", visibility: "query" },
{ id: "pane:split-down", visibility: "query" },
]);
});
it("omits browser and split actions when their existing capabilities are unavailable", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
fixture.value.capabilities = {
canSplitPanes: false,
canOpenBrowserTabs: false,
isGit: false,
};
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
});
it("dispatches every tab and pane command to the workspace scope", () => {
@@ -113,13 +164,8 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched).toEqual([
{ id: "workspace.agent.new", scope: "workspace" },
{ id: "workspace.terminal.new", scope: "workspace" },
{ id: "workspace.browser.new", scope: "workspace" },
{ id: "workspace.pane.split.right", scope: "workspace" },
{ id: "workspace.pane.split.down", scope: "workspace" },
]);
expect(fixture.dispatched.length).toBeGreaterThan(5);
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
});
it("keeps workspace creation commands available outside Git", () => {
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual([
"tab:new-agent",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
]);
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
});
@@ -1,5 +1,10 @@
import type { GitAction, GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
WorkspacePanelTarget,
} from "@/keyboard/keyboard-action-dispatcher";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
newBrowser: string;
splitRight: string;
splitDown: string;
changes: string;
files: string;
pullRequest: string;
openPanel(name: string, placement: WorkspacePanelPlacement): string;
previousTab: string;
nextTab: string;
closeCurrentTab: string;
renameTab: string;
reloadAgent: string;
copyResumeCommand: string;
copyAgentId: string;
copyTerminalId: string;
copyFilePath: string;
closeTabsLeft: string;
closeTabsRight: string;
closeOtherTabs: string;
focusPaneLeft: string;
focusPaneRight: string;
focusPaneUp: string;
focusPaneDown: string;
moveTabLeft: string;
moveTabRight: string;
moveTabUp: string;
moveTabDown: string;
closePane: string;
togglePaneMaximization: string;
toggleFocusMode: string;
toggleSidePanel: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
newBrowser?: CommandCenterIcon;
splitRight?: CommandCenterIcon;
splitDown?: CommandCenterIcon;
changes?: CommandCenterIcon;
files?: CommandCenterIcon;
pullRequest?: CommandCenterIcon;
previousTab?: CommandCenterIcon;
nextTab?: CommandCenterIcon;
close?: CommandCenterIcon;
rename?: CommandCenterIcon;
reload?: CommandCenterIcon;
copy?: CommandCenterIcon;
focusPane?: CommandCenterIcon;
moveTab?: CommandCenterIcon;
maximize?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
sidePanel?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
splitRight?: ShortcutKey[][];
splitDown?: ShortcutKey[][];
archiveWorkspace?: ShortcutKey[][];
previousTab?: ShortcutKey[][];
nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][];
togglePaneMaximization?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleSidePanel?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
capabilities: {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
isGit: boolean;
};
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
}
@@ -96,7 +154,322 @@ function buildWorkspaceAction(input: {
};
}
export function buildWorkspaceCommandCenterContributions(
type QueryActionInput = Omit<Parameters<typeof buildWorkspaceAction>[0], "source" | "visibility">;
function buildQueryAction(
source: WorkspaceCommandCenterSource,
input: QueryActionInput,
): CommandCenterContribution {
return buildWorkspaceAction({ ...input, source, visibility: "query" });
}
function buildPanelContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
const panelTargets: Array<{
target: WorkspacePanelTarget;
name: string;
icon?: CommandCenterIcon;
}> = [
{ target: "changes", name: source.labels.changes, icon: source.icons.changes },
{ target: "files", name: source.labels.files, icon: source.icons.files },
{
target: "pull-request",
name: source.labels.pullRequest,
icon: source.icons.pullRequest,
},
];
for (const [index, panel] of panelTargets.entries()) {
if (panel.target !== "files" && !source.capabilities.isGit) continue;
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}`,
rank: 4 + index,
title: source.labels.openPanel(panel.name, "supporting"),
keywords: ["open", panel.target, "tab", "supporting"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement: "supporting",
},
}),
);
if (!source.capabilities.canSplitPanes) continue;
for (const [placementIndex, placement] of (["side-panel", "focused-pane"] as const).entries()) {
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}:${placement}`,
rank: 10 + index * 2 + placementIndex,
title: source.labels.openPanel(panel.name, placement),
keywords: ["open", panel.target, "tab", placement, "pane"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement,
},
}),
);
}
}
return contributions;
}
function buildActiveTabContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
if (source.activeTabCount === 0) return [];
const contributions: CommandCenterContribution[] = [
buildQueryAction(source, {
id: "tab:previous",
rank: 30,
title: source.labels.previousTab,
keywords: ["tab", "previous", "switch", "navigate"],
icon: source.icons.previousTab,
shortcutKeys: source.shortcuts.previousTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
}),
buildQueryAction(source, {
id: "tab:next",
rank: 31,
title: source.labels.nextTab,
keywords: ["tab", "next", "switch", "navigate"],
icon: source.icons.nextTab,
shortcutKeys: source.shortcuts.nextTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 },
}),
buildQueryAction(source, {
id: "tab:close-current",
rank: 32,
title: source.labels.closeCurrentTab,
keywords: ["tab", "close", "current"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closeCurrentTab,
action: { id: "workspace.tab.close-current", scope: "workspace" },
}),
];
if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") {
contributions.push(
buildQueryAction(source, {
id: "tab:rename-current",
rank: 33,
title: source.labels.renameTab,
keywords: ["tab", "rename", "current"],
icon: source.icons.rename,
action: { id: "workspace.tab.rename-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-id",
rank: 34,
title:
source.activeTabKind === "agent"
? source.labels.copyAgentId
: source.labels.copyTerminalId,
keywords: ["tab", "copy", "id"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-id", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "agent") {
contributions.push(
buildQueryAction(source, {
id: "tab:reload-current",
rank: 35,
title: source.labels.reloadAgent,
keywords: ["tab", "agent", "reload"],
icon: source.icons.reload,
action: { id: "workspace.tab.reload-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-resume-command",
rank: 36,
title: source.labels.copyResumeCommand,
keywords: ["tab", "agent", "copy", "resume", "command"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-resume-command", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "file") {
contributions.push(
buildQueryAction(source, {
id: "tab:copy-file-path",
rank: 37,
title: source.labels.copyFilePath,
keywords: ["tab", "file", "copy", "path"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-file-path", scope: "workspace" },
}),
);
}
if (source.activeTabIndex > 0) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-left",
rank: 38,
title: source.labels.closeTabsLeft,
keywords: ["tab", "close", "left"],
icon: source.icons.close,
action: { id: "workspace.tab.close-left", scope: "workspace" },
}),
);
}
if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-right",
rank: 39,
title: source.labels.closeTabsRight,
keywords: ["tab", "close", "right"],
icon: source.icons.close,
action: { id: "workspace.tab.close-right", scope: "workspace" },
}),
);
}
if (source.activeTabCount > 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-others",
rank: 40,
title: source.labels.closeOtherTabs,
keywords: ["tab", "close", "others"],
icon: source.icons.close,
action: { id: "workspace.tab.close-others", scope: "workspace" },
}),
);
}
return contributions;
}
function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] {
const paneActions: Array<{
id: string;
rank: number;
title: string;
keywords: string[];
action: KeyboardActionDefinition;
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
}> = [
{
id: "pane:split-right",
rank: 50,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
},
{
id: "pane:split-down",
rank: 51,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
},
{
id: "pane:focus-left",
rank: 52,
title: source.labels.focusPaneLeft,
keywords: ["pane", "focus", "left"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.left", scope: "workspace" },
},
{
id: "pane:focus-right",
rank: 53,
title: source.labels.focusPaneRight,
keywords: ["pane", "focus", "right"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.right", scope: "workspace" },
},
{
id: "pane:focus-up",
rank: 54,
title: source.labels.focusPaneUp,
keywords: ["pane", "focus", "up"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.up", scope: "workspace" },
},
{
id: "pane:focus-down",
rank: 55,
title: source.labels.focusPaneDown,
keywords: ["pane", "focus", "down"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.down", scope: "workspace" },
},
{
id: "pane:move-tab-left",
rank: 56,
title: source.labels.moveTabLeft,
keywords: ["pane", "tab", "move", "left"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.left", scope: "workspace" },
},
{
id: "pane:move-tab-right",
rank: 57,
title: source.labels.moveTabRight,
keywords: ["pane", "tab", "move", "right"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.right", scope: "workspace" },
},
{
id: "pane:move-tab-up",
rank: 58,
title: source.labels.moveTabUp,
keywords: ["pane", "tab", "move", "up"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.up", scope: "workspace" },
},
{
id: "pane:move-tab-down",
rank: 59,
title: source.labels.moveTabDown,
keywords: ["pane", "tab", "move", "down"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.down", scope: "workspace" },
},
{
id: "pane:close",
rank: 60,
title: source.labels.closePane,
keywords: ["pane", "close"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closePane,
action: { id: "workspace.pane.close", scope: "workspace" },
},
{
id: "pane:maximize-toggle",
rank: 61,
title: source.labels.togglePaneMaximization,
keywords: ["pane", "maximize", "restore", "toggle"],
icon: source.icons.maximize,
shortcutKeys: source.shortcuts.togglePaneMaximization,
action: { id: "workspace.explorer.maximize.toggle", scope: "workspace" },
},
{
id: "pane:focus-mode-toggle",
rank: 62,
title: source.labels.toggleFocusMode,
keywords: ["pane", "focus", "mode", "toggle"],
icon: source.icons.focusMode,
shortcutKeys: source.shortcuts.toggleFocusMode,
action: { id: "workspace.focus.toggle", scope: "workspace" },
},
];
return paneActions.map((action) => buildQueryAction(source, action));
}
function buildCreationContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
const primary = source.gitActions.primary;
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-terminal",
rank: 2,
title: source.labels.newTerminal,
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
icon: source.icons.newTerminal,
shortcutKeys: source.shortcuts.newTerminal,
action: { id: "workspace.terminal.new", scope: "workspace" },
visibility: "query",
}),
);
if (source.capabilities.canOpenBrowserTabs) {
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-browser",
rank: 3,
title: source.labels.newBrowser,
keywords: ["browser", "web", "preview"],
icon: source.icons.newBrowser,
action: { id: "workspace.browser.new", scope: "workspace" },
visibility: "query",
}),
);
}
if (source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "pane:split-right",
rank: 4,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceAction({
source,
id: "pane:split-down",
rank: 5,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
visibility: "query",
}),
);
}
return contributions;
}
export function buildWorkspaceCommandCenterContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions = [
...buildCreationContributions(source),
...buildPanelContributions(source),
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "side-panel:toggle",
rank: 70,
title: source.labels.toggleSidePanel,
keywords: ["side", "panel", "toggle", "show", "hide"],
icon: source.icons.sidePanel,
shortcutKeys: source.shortcuts.toggleSidePanel,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
return contributions;
}
@@ -1,18 +1,41 @@
import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
import {
ArrowDownToLine,
ArrowLeft,
ArrowRight,
Columns2,
Copy,
Files,
Focus,
GitCompareArrows,
GitPullRequest,
Globe,
Maximize2,
Move,
PanelRight,
Pencil,
RotateCw,
Rows2,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { getIsElectron } from "@/constants/platform";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import {
resolveShortcutKeysForAction,
type ShortcutOverrides,
} from "@/keyboard/keyboard-shortcuts";
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import {
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon";
@@ -22,6 +45,7 @@ import {
buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterShortcuts,
} from "./workspace-contributions";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
const WORKSPACE_COMMAND_CENTER_ICONS = {
newAgent: getCommandCenterIcon(SquarePen),
@@ -29,8 +53,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
newBrowser: getCommandCenterIcon(Globe),
splitRight: getCommandCenterIcon(Columns2),
splitDown: getCommandCenterIcon(Rows2),
changes: getCommandCenterIcon(GitCompareArrows),
files: getCommandCenterIcon(Files),
pullRequest: getCommandCenterIcon(GitPullRequest),
previousTab: getCommandCenterIcon(ArrowLeft),
nextTab: getCommandCenterIcon(ArrowRight),
close: getCommandCenterIcon(X),
rename: getCommandCenterIcon(Pencil),
reload: getCommandCenterIcon(RotateCw),
copy: getCommandCenterIcon(Copy),
focusPane: getCommandCenterIcon(Focus),
moveTab: getCommandCenterIcon(Move),
maximize: getCommandCenterIcon(Maximize2),
focusMode: getCommandCenterIcon(ArrowDownToLine),
sidePanel: getCommandCenterIcon(PanelRight),
};
const OPEN_PANEL_LABEL_KEYS = {
supporting: "shell.commandCenter.open",
"side-panel": "shell.commandCenter.openInSidePanel",
"focused-pane": "shell.commandCenter.openInFocusedPane",
} as const;
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
if (!element) return undefined;
function StaticIcon() {
@@ -41,17 +85,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
return {
newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined,
newTerminal:
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
splitRight:
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
splitDown:
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace:
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
};
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
}
export function useWorkspaceCommandCenterActions(): void {
@@ -60,10 +94,22 @@ export function useWorkspaceCommandCenterActions(): void {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const workspaceKey =
serverId && workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
);
const focusedPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
const focusedTabs = layout
? collectAllTabs(layout.root).filter((tab) => focusedPane?.tabIds.includes(tab.tabId))
: [];
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions } = useGitActions({
const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "",
cwd: cwd ?? "",
icons: GIT_ACTION_ICONS,
@@ -81,6 +127,34 @@ export function useWorkspaceCommandCenterActions(): void {
newBrowser: t("workspace.tabs.actions.newBrowser"),
splitRight: t("workspace.tabs.actions.splitRight"),
splitDown: t("workspace.tabs.actions.splitDown"),
changes: t("workspace.tabs.actions.changes"),
files: t("workspace.tabs.actions.files"),
pullRequest: t("workspace.tabs.actions.pullRequest"),
openPanel: (name, placement) => t(OPEN_PANEL_LABEL_KEYS[placement], { name }),
previousTab: t("settings.shortcuts.help.previousTab"),
nextTab: t("settings.shortcuts.help.nextTab"),
closeCurrentTab: t("settings.shortcuts.help.closeCurrentTab"),
renameTab: t("workspace.tabs.menu.rename"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
closeTabsLeft: t("workspace.tabs.menu.closeLeft"),
closeTabsRight: t("workspace.tabs.menu.closeRight"),
closeOtherTabs: t("workspace.tabs.menu.closeOthers"),
focusPaneLeft: t("settings.shortcuts.help.focusPaneLeft"),
focusPaneRight: t("settings.shortcuts.help.focusPaneRight"),
focusPaneUp: t("settings.shortcuts.help.focusPaneUp"),
focusPaneDown: t("settings.shortcuts.help.focusPaneDown"),
moveTabLeft: t("settings.shortcuts.help.moveTabLeft"),
moveTabRight: t("settings.shortcuts.help.moveTabRight"),
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
closePane: t("settings.shortcuts.help.closePane"),
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -90,14 +164,29 @@ export function useWorkspaceCommandCenterActions(): void {
capabilities: {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
isGit,
},
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
}),
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
[
activeTabIndex,
activeTabKind,
focusedTabs.length,
gitActions,
isCompact,
isGit,
keyboardActionDispatcher,
overrides,
runGitAction,
t,
],
);
useCommandCenterActions({
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
describe("resolveWorkspaceCommandCenterShortcuts", () => {
it("assigns the New agent command its own shortcut", () => {
expect(
resolveWorkspaceCommandCenterShortcuts({
overrides: {},
platform: { isMac: true, isDesktop: true },
}).newAgent,
).toEqual([["mod", "shift", "A"]]);
});
});
@@ -0,0 +1,39 @@
import {
resolveShortcutKeysForAction,
type ShortcutOverrides,
} from "@/keyboard/keyboard-shortcuts";
import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions";
interface ResolveWorkspaceCommandCenterShortcutsInput {
overrides: ShortcutOverrides;
platform: { isMac: boolean; isDesktop: boolean };
}
export function resolveWorkspaceCommandCenterShortcuts({
overrides,
platform,
}: ResolveWorkspaceCommandCenterShortcutsInput): WorkspaceCommandCenterShortcuts {
return {
newAgent:
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, platform) ?? undefined,
newTerminal:
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
splitRight:
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
splitDown:
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace:
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
previousTab:
resolveShortcutKeysForAction("workspace-tab-prev", overrides, platform) ?? undefined,
nextTab: resolveShortcutKeysForAction("workspace-tab-next", overrides, platform) ?? undefined,
closeCurrentTab:
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
togglePaneMaximization:
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
toggleSidePanel:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
};
}
@@ -1662,7 +1662,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surfaceSidebar,
},
desktopSplit: {
flex: 1,
@@ -0,0 +1,16 @@
import Svg, { Circle, Path } from "react-native-svg";
import type { SvgPathIconProps } from "./svg-path-icon";
// Forge-neutral rendering of GitLab's manual-status gear, aligned with the
// proportions of the other pipeline status icons.
const MANUAL_STATUS_GEAR_PATH =
"M10.5 7.63V6.37l-.787-.13c-.044-.175-.132-.349-.263-.61l.481-.652-.918-.913-.657.478a2.346 2.346 0 0 0-.612-.26L7.656 3.5H6.388l-.132.783c-.219.043-.394.13-.612.26l-.657-.478-.918.913.437.652c-.131.218-.175.392-.262.61l-.744.086v1.261l.787.13c.044.218.132.392.263.61l-.438.651.92.913.655-.434c.175.086.394.173.613.26l.131.783h1.313l.131-.783c.219-.043.394-.13.613-.26l.656.478.918-.913-.48-.652c.13-.218.218-.435.262-.61l.656-.13zM7 8.283a1.285 1.285 0 0 1-1.313-1.305c0-.739.57-1.304 1.313-1.304.744 0 1.313.565 1.313 1.304 0 .74-.57 1.305-1.313 1.305z";
export function ManualStatusIcon({ size = 16, color = "currentColor" }: SvgPathIconProps) {
return (
<Svg width={size} height={size} viewBox="-0.5 -0.5 15 15">
<Circle cx={7} cy={7} r={6.25} fill="none" stroke={color} strokeWidth={1.25} />
<Path d={MANUAL_STATUS_GEAR_PATH} fill={color} />
</Svg>
);
}
+14 -2
View File
@@ -75,12 +75,14 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { MarkdownFenceBlock } from "@/components/markdown/fence";
import type { MarkdownPhase } from "@/components/markdown/fence/types";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
import { isRenderProfileEnabled } from "@/utils/render-profiler";
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
@@ -1459,6 +1461,9 @@ export const AssistantMessage = memo(function AssistantMessage({
phase,
}: AssistantMessageProps) {
const markdownParser = useMemo(createAssistantMarkdownParser, []);
// Paint a paced prefix while the turn is streaming so text arrives at a steady
// rate instead of in whatever lumps the daemon's coalescing window produced.
const revealedMessage = useRevealedText(message, phase);
const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => {
@@ -1897,7 +1902,7 @@ export const AssistantMessage = memo(function AssistantMessage({
};
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const blocks = useMemo(() => splitMarkdownBlocks(revealedMessage), [revealedMessage]);
const keyedBlocks = useMemo(
() => blocks.map((block, index) => ({ key: `block:${index}`, block })),
[blocks],
@@ -1913,9 +1918,16 @@ export const AssistantMessage = memo(function AssistantMessage({
],
[spacing],
);
const revealDataSet = useMemo(
() =>
isRenderProfileEnabled()
? { revealKey: occurrenceKey, revealLength: String(revealedMessage.length) }
: undefined,
[occurrenceKey, revealedMessage.length],
);
return (
<View testID="assistant-message" style={assistantContainerStyle}>
<View testID="assistant-message" dataSet={revealDataSet} style={assistantContainerStyle}>
{keyedBlocks.map(({ key, block }, index) => (
<AssistantMessageBlockContainer
key={key}
+14 -2
View File
@@ -13,7 +13,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
@@ -1455,7 +1455,19 @@ function ModelBrowserContent({
return scrolling === "independent" ? (
<IndependentProviderList>{allProvidersContent}</IndependentProviderList>
) : (
allProvidersContent
<BottomSheetScrollView
style={styles.virtualizedModelList}
contentContainerStyle={[
styles.virtualizedModelListContent,
styles.virtualizedProviderListContent,
]}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
testID="compact-provider-list"
>
{allProvidersContent}
</BottomSheetScrollView>
);
}
@@ -9,12 +9,13 @@ import {
} from "react";
interface RewindComposerRestoreContextValue {
restoreTextIfComposerEmpty: (text: string) => void;
completeRewind: (text: string) => void;
}
interface RewindComposerRestoreProviderProps {
text: string;
setText: (text: string) => void;
onRewindComplete: () => void;
children: ReactNode;
}
@@ -33,6 +34,7 @@ export function restoreComposerTextIfEmpty(input: {
export function RewindComposerRestoreProvider({
text,
setText,
onRewindComplete,
children,
}: RewindComposerRestoreProviderProps) {
const textRef = useRef(text);
@@ -41,7 +43,7 @@ export function RewindComposerRestoreProvider({
textRef.current = text;
}, [text]);
const restoreTextIfComposerEmpty = useCallback(
const completeRewind = useCallback(
(rewoundText: string) => {
const nextText = restoreComposerTextIfEmpty({
currentText: textRef.current,
@@ -50,11 +52,12 @@ export function RewindComposerRestoreProvider({
if (nextText !== textRef.current) {
setText(nextText);
}
onRewindComplete();
},
[setText],
[onRewindComplete, setText],
);
const value = useMemo(() => ({ restoreTextIfComposerEmpty }), [restoreTextIfComposerEmpty]);
const value = useMemo(() => ({ completeRewind }), [completeRewind]);
return (
<RewindComposerRestoreContext.Provider value={value}>
@@ -52,7 +52,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
return;
}
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
composerRestore?.completeRewind(variables.rewoundText);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
+26 -88
View File
@@ -69,7 +69,6 @@ import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -110,10 +109,7 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfile }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
onCreateNewTab: (input: { paneId?: string }) => void;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
@@ -129,14 +125,8 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onClosePane: (paneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: (input: {
paneId: string;
paneIsolated: boolean;
onClosePane: () => void;
}) => ReactNode;
focusModeEnabled?: boolean;
onExitFocusMode: () => void;
}
@@ -182,7 +172,6 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
sidePanelPaneId: string | null;
maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void;
paneIsolated: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
@@ -402,19 +391,14 @@ export function SplitContainer({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
onCreateNewTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
focusModeEnabled,
onExitFocusMode,
}: SplitContainerProps) {
@@ -502,12 +486,6 @@ export function SplitContainer({
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
// Isolating a pane (focus mode, maximize) hides its siblings without removing
// them, so the one pane on screen is not really the last one and closing it is not
// what anyone means. That is all this container knows; whether the layout itself
// allows the dismissal is `canDismissPaneInLayout`, which the caller reads.
const paneIsolated = focusModeEnabled || Boolean(maximizedPaneId);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
if (!data) {
@@ -687,19 +665,13 @@ export function SplitContainer({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
@@ -898,19 +870,13 @@ function SplitNodeView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
onCreateNewTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
paneIsolated,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
@@ -979,18 +945,12 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
@@ -1033,19 +993,13 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
@@ -1095,18 +1049,12 @@ function SplitPaneView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
onCreateNewTab,
buildPaneContentModel,
onFocusPane,
onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty,
paneIsolated,
onClosePane,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
@@ -1218,7 +1166,6 @@ function SplitPaneView({
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View
@@ -1247,10 +1194,7 @@ function SplitPaneView({
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
externalDndContext
activeDragTabId={activeDragTabId}
@@ -1266,31 +1210,25 @@ function SplitPaneView({
</WindowChromeSafeArea>
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0
? mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
{mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
: (renderPaneEmptyState?.({
paneId,
paneIsolated,
onClosePane: handleClosePane,
}) ?? null)}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
const BUILDS = {
// Metro resolves src; Node consumers resolve one of the compiled builds.
src: "react-native-svg/src/lib/extract/extractTransform.ts",
esm: "react-native-svg/lib/module/lib/extract/extractTransform",
cjs: "react-native-svg/lib/commonjs/lib/extract/extractTransform.js",
} as const;
const CSS_MODULE = "react-native-svg/src/css/index.tsx";
const XML_MODULE = "react-native-svg/src/xml.tsx";
type Extractor = (props: { transform?: unknown }) => unknown;
type XmlMiddleware = (root: unknown) => unknown;
interface ParsedRoot {
props: Record<string, unknown>;
}
type XmlParser = (source: string, middleware?: XmlMiddleware) => ParsedRoot | null;
async function loadBuild(extractSpecifier: string) {
const [cssModule, extractModule, xmlModule] = (await Promise.all([
import(/* @vite-ignore */ CSS_MODULE),
import(/* @vite-ignore */ extractSpecifier),
import(/* @vite-ignore */ XML_MODULE),
])) as [
{ inlineStyles?: XmlMiddleware; default?: { inlineStyles?: XmlMiddleware } },
{
extractTransformSvgView?: Extractor;
default?: { extractTransformSvgView?: Extractor };
},
{ parse?: XmlParser; default?: { parse?: XmlParser } },
];
const inlineStyles = cssModule.inlineStyles ?? cssModule.default?.inlineStyles;
const extract =
extractModule.extractTransformSvgView ?? extractModule.default?.extractTransformSvgView;
const parseXml = xmlModule.parse ?? xmlModule.default?.parse;
if (!inlineStyles || !extract || !parseXml)
throw new Error(`could not load react-native-svg build`);
return (xml: string) => {
const root = parseXml(xml, inlineStyles);
if (!root) throw new Error(`could not parse root SVG`);
const props = { ...root.props };
const style = props.style;
if (style && typeof style === "object" && "transform" in style) {
props.transform = style.transform;
}
return extract(props);
};
}
describe.each(Object.entries(BUILDS))("root SVG transforms (%s build)", (_name, specifier) => {
it("still converts a real SVG transform string", async () => {
const extractFromXml = await loadBuild(specifier);
expect(extractFromXml(`<svg transform="rotate(-90)"></svg>`)).toEqual([{ rotate: "-90deg" }]);
});
it.each([
["an inline CSS keyword", `<svg style="transform:none"></svg>`],
["an embedded CSS rule", `<svg><style>svg { transform: none; }</style></svg>`],
["a CSS angle unit", `<svg transform="rotate(-90deg)"></svg>`],
["a CSS length unit", `<svg transform="translate(10px, 0)"></svg>`],
])("drops %s from parsed root SVG props", async (_label, xml) => {
const extractFromXml = await loadBuild(specifier);
expect(() => extractFromXml(xml)).not.toThrow();
expect(extractFromXml(xml)).toBeUndefined();
});
});
@@ -86,23 +86,28 @@ describe("control geometry", () => {
expect(geometry.formTextInputMd.lineHeight).toBe(20);
});
it("derives field padding from line height without changing the control height", () => {
it("derives field padding from content and border without changing the control height", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
geometry.fieldControlSm.minHeight,
);
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight);
expect(geometry.fieldControlMd.minHeight).toBe(44);
expect(geometry.fieldControlMd.paddingVertical).toBe(12);
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
geometry.fieldControlMd.minHeight,
);
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlMd.minHeight);
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
expect(geometry.formTextInputMd.paddingVertical).toBe(12);
expect(geometry.formTextInputSm.paddingVertical).toBe(5);
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
});
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => {
@@ -78,8 +78,12 @@ function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
}
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
return (controlHeight - lineHeight) / 2;
function fieldVerticalPadding(
controlHeight: number,
lineHeight: number,
borderWidth: number,
): number {
return (controlHeight - lineHeight - borderWidth * 2) / 2;
}
export function getControlInteractionPhase(
@@ -111,18 +115,27 @@ export function resolveControlInteractionStyles(
}
export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1];
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3],
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.compact, fieldTextSmLineHeight),
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.compact,
fieldTextSmLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlMd = {
minHeight: CONTROL_HEIGHTS.field,
paddingHorizontal: theme.spacing[4],
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.field, fieldTextMdLineHeight),
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.field,
fieldTextMdLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.lg,
};
const fieldTextSm = {
@@ -182,7 +195,7 @@ export function createControlGeometry(theme: Theme) {
fieldTextSm,
fieldTextMd,
controlRest: {
borderWidth: theme.borderWidth[1],
borderWidth: controlBorderWidth,
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",

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