Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 62a5eac186 test(e2e): keep developer toasts out of concurrent app assertions
Concurrent layout measurements could invoke the same Playwright locator handler before its first dismissal finished, leaving checks waiting for a removed button. Hide only the Expo toast host through test styling while retaining console error capture.
2026-10-20 08:37:57 +02:00
Mohamed Boudra 398c1a3b52 fix(server): avoid duplicate cwd in worktree provisioning request 2026-10-20 08:01:39 +02:00
Mohamed Boudra c138903a9c refactor(workspaces): own setup exclusively at the workspace boundary
Preserve keyed modern agent worktree creation while retiring legacy placement that selected an agent-owned setup lifecycle. Keep output in per-command workspace snapshots and retain empty strings required by existing wire schemas.
2026-10-20 07:55:24 +02:00
Mohamed Boudra 56f0ad3000 perf(workspaces): stop duplicating setup output in progress snapshots
Keep the required combined log field empty for setup clients, which display per-command output. Preserve concatenation only for legacy agent bootstrap timeline cards whose released renderers still read it directly.
2026-10-20 07:32:31 +02:00
paseo-ai[bot] 3081b99b06 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-20 05:08:53 +00:00
Mohamed Boudra adf27de7d0 Keep Explorer consistent across phone, tablet and desktop layouts (#7424)
* Upgrade to Expo 57 and unify Explorer across layouts

* feat(app): retain Explorer across layout and appearance changes

Keep one workspace Explorer host and destination-aware navigation so folding, theme updates and compact sheets preserve the current panel. Scope pull request discovery and timeline demand to active surfaces.

* fix(app): keep Explorer resize handles stretched through the dock

The shared frame moved the divider into an absolute column, leaving its horizontal handle without height. Restore the row so pointer drags reach it, preserving the existing resize and performance contract.

* fix(app): keep Explorer drag widths out of the shared stylesheet

Carry the existing inline sizing policy into the shared frame so pointer previews do not accumulate CSS rules.

* fix(test): align isolated test runtimes with Expo 57

Hoisted React consumers need the matching DOM renderer at the repository root. Automated Metro processes must not install native DevTools into disposable homes, and development toast overlays must not cover compact actions.

Keep Explorer journeys on the shared host and synchronize setup fixtures on daemon output rather than a one-second file-marker window. A delayed real child start reproduces the readiness failure on every platform.

* fix(test): await retained shell layout after composer resize

The gesture host now stays mounted across breakpoints, so its presence no longer identifies compact layout. Report the current layout on the retained host and synchronize composer journeys with that state.

* fix(app): preserve browser interactions in retained workspace hosts

Initially disabled gesture hosts still registered pointer and wheel listeners, while hover cards used window positions inside a workspace-local sheet portal. Respect initial gesture enablement and position cards relative to their measured floating host.

Keep developer-toast dismissal independent of pointer input and replacement toast visibility so modal, hover, and focus checks retain their input state.

* fix(test): share developer toast handling with Electron browser runs

The standalone Electron browser runner bypassed the browser fixture, allowing developer errors to cover Settings. Use the same non-pointer dismissal in both runners while retaining their console logs.
2026-10-20 11:59:49 +07:00
paseo-bot[bot] ab6cdb7c89 fix(claude): return to Auto when approving a plan (#7453)
Offer Auto the same explicit resume action as Bypass and apply the saved mode before releasing plan approval. Ordinary Implement keeps selecting Accept File Edits.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-20 03:57:25 +02:00
paseo-bot[bot] 0d61cca50c fix(app): identify Android certificate failures as TLS errors (#7429)
Recognize Android certificate-path validation failures before the generic not-found classification, so Direct connection no longer tells users to debug DNS for an untrusted certificate. Keep the existing TLS copy and technical details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-20 00:53:37 +02:00
paseo-bot[bot] 0eee4fd9b0 docs(schedules): explain archive-on-finish behavior (#7426)
The schedule and worktree guides omitted the existing retention control and its default. Document the app setting and clarify that CLI and MCP tools do not expose it.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 23:56:49 +02:00
paseo-bot[bot]andPete Keen 69ef741a73 fix(app): pause native status rings in hidden panels (#7423)
Retained panels keep their children mounted, so mount-only registration kept hidden rings subscribed to the shared UI clock. Detach inactive rings and retain a local phase so hidden animated styles also stop receiving updates while visible rings continue.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Pete Keen <pete@petekeen.net>
2026-10-19 23:26:01 +02:00
paseo-bot[bot] 90f3cab585 fix(nix): report the project license in package metadata (#7415)
The Apache-2.0 relicensing left both Nix derivations declaring AGPL. Match their metadata to the project license and check both packages against the root manifest on every supported Nix system.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 21:58:09 +02:00
paseo-bot[bot]andIvan Malison fc775b6db5 Load translations when their language is selected (#7410)
* perf(app): load translations when their language is selected

Eager locale imports evaluated and registered all nine translation bundles on every cold start. Load bundled locales through a synchronous i18next backend so language selection keeps its before-render contract and offline behavior.

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

* test(app): normalize translation entry paths across platforms

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-10-19 21:03:24 +02:00
paseo-bot[bot]anddwyanewang 71e7cf6ff8 Build native Android modules with the project NDK (#7403)
* fix(android): align Unistyles NDK version

* fix(android): use the project NDK for every native module

The diff module still selected the Android Gradle default NDK, causing a second installation after Unistyles was aligned. Inherit the project toolchain and check all configured native projects.

---------

Co-authored-by: dwyanewang <908280968@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 20:09:07 +02:00
paseo-bot[bot]andMichael Wu eda3e4318f Clarify and localize provider usage (#7376)
* Localize provider usage UI

* fix: restore compact provider usage meter

* fix: localize compact context percentage

* fix(app): deduplicate context meter refresh

* fix(app): localize provider usage time counts

* fix(app): stabilize compact provider usage row

* fix(app): localize provider usage plurals

* test(app): allow locale-specific plural keys

* fix(app): show compact quota without context usage

* fix(app): preserve context overflow percentage

* fix(app): truncate provider balance reset values

* fix(app): add Korean provider usage translations

* fix(app): refresh provider usage timing

* fix(app): localize provider usage labels

* fix(app): clarify compact usage policies

* fix(app): localize monthly provider usage

* test(app): require Arabic provider usage plurals

* docs(i18n): keep migration guidance focused

* fix(app): hide empty compact usage slot

* fix(app): keep usage in context tooltip

* fix(app): align usage with sidebar reports

* test(app): pass usage timing clock explicitly

* fix(app): restore context usage details

* fix(app): make context usage tooltip interactive

* fix(app): support keyboard tooltip controls

* fix(app): keep focused tooltip controls open

* fix(app): address usage localization review

* fix(app): localize bulk usage refresh

* fix(app): localize Claude session window

* fix(app): complete provider usage localization

* fix(app): keep mounted usage times and translations current

Pass time and translation values into usage rendering so React Compiler invalidates cached labels when they change. Use the same localized window names and percentage meanings in sidebar summaries.

* test(desktop): wait for restored tabs before shortcut input

Document load completes before the workspace reconnects. Wait for the restored selection so Ctrl+1 reaches the mounted workspace rather than the startup screen.

* test(app): allow the shared usage clock minute phase

The browser clock advances independently of daemon timestamps, and the shared ticker samples on wall-minute boundaries. Assert the bounded one-to-two-minute age and matching countdown progress instead of a single phase-dependent value.

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 18:01:45 +02:00
paseo-bot[bot]andgszep 91154ba4e8 fix(opencode): advertise agent-scoped bridge tool schemas (#7383)
Co-authored-by: gszep <contact@gszep.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 16:58:21 +02:00
paseo-bot[bot] 05920a236c test(codex): observe permission events in auto-review scenarios (#7382)
List snapshots do not subscribe modern clients to permission events. Activate that feed before both scenarios so the request assertion and auto-review absence assertion exercise delivery.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 15:49:47 +02:00
paseo-bot[bot] dd244c076d fix(pi): remove running rows for refused subagent resumes (#7369)
A text-only refusal finishes successfully without identifying a child. Remove its provisional subagent row when the tool completes, while retaining the refusal in the tool timeline.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 14:11:41 +02:00
Mohamed Boudra 1c8c68f93e Keep workspace setup logs scoped to the visible setup panel (#7407)
* fix(workspaces): observe setup output only in the visible setup panel

Setup logs were requested for the whole session, including unrelated workspaces. Use directory status for discovery and source-owned workspace observations for details, preserving legacy delivery only for clients without the negotiated capability.

* fix(workspaces): publish external setup progress once per daemon

MCP and scheduled setup enter through the broadcast API. Publish them to the shared setup owner before session fanout so each observation gets one update and snapshots survive periods without connected clients. Keep the setup action available on older hosts to explain the required host update.

* fix(app): replace setup details with each observation snapshot

A host restart can return an empty setup snapshot. Apply that authoritative snapshot so an open panel does not retain output from the previous daemon. Assert setup membership requests as well as delivery in the browser lifecycle regression.

* test(workspaces): cover setup status catch-up after directory release

* test(workspaces): synchronize setup output and release fixture resources
2026-10-19 18:34:53 +07:00
paseo-bot[bot] 19749baeec Keep Android settings inputs visible above the keyboard (#7362)
* fix(app): reveal Android settings inputs above the keyboard

PageLayout kept its full-height scroll viewport while the keyboard controller handled Android insets, leaving Code size covered. Use the existing keyboard-aware scroller for Android pages and cover the real focused field and keyboard geometry in the mobile regression harness.

* fix(app): keep settings field frames above the Android keyboard

Keyboard scrolling revealed only the native text input, leaving the enclosing field border and padding covered. Allow theme spacing around the input and assert the visible Plugins source frame against the real keyboard.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 13:11:20 +02:00
paseo-bot[bot] e760764b37 fix(app): require a number when recording index shortcuts (#7365)
Tab and workspace navigation need a final 1–9 index. Prevent saving an incomplete recorded sequence and explain the requirement while preserving arbitrary chord prefixes.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 11:55:10 +02:00
Mohamed Boudra b6dde61a7f feat(plugins): accept inline SVG and component icons (#7368)
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.

The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
2026-10-19 15:36:04 +07:00
Mohamed Boudra 1e0e56c721 refactor(app): unify built-in file panel selection (#7366)
* refactor(app): unify built-in file panel selection

* refactor(app): resolve file panels once per pane
2026-10-19 15:35:41 +07:00
c575464e8f Skip saving unchanged workspace layouts (#7358)
* perf(app): throttle workspace layout writes and skip no-op persists

Zustand persist calls setItem after every set, even when the updater
returns the same state. The layout store re-normalized every workspace
tree, re-validated the envelope, stringified it, and wrote to
AsyncStorage on each agent status change because reconcileTabs runs from
an effect keyed on agent visibility.

The draft store's throttled writer moves to storage/ as a generic
throttled persist storage, gains a referential short-circuit for a value
whose partialized state was already accepted, and now backs both the
draft and layout stores. The layout store's partialize reuses its
previous projection while every persisted slice is the same reference,
and the store is flushed alongside drafts when the app leaves the
foreground.

Before: 50 no-op reconciles produced 50 writes. After: 0 writes; 50 real
sets inside one interval produce 2 writes (leading and trailing).

Fixes #4814

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

* test(app): build layout stores with options and flush before rehydrating

Six call sites still passed the id factories positionally, which this
branch replaced with an options object, so those stores silently used
default ids. The once-only PR test also rehydrated a second store before
the throttled write landed; flush the source first.

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

* fix(app): avoid shadowing layout options

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 10:02:25 +02:00
paseo-ai[bot] 9a0b95aa10 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-19 07:09:09 +00:00
paseo-bot[bot]andaz33zy 9be1547741 fix(app): show a promoted git action once in the compact menu (#7355)
The compact Changes toolbar flattens every git action into one menu, and
`secondary` keeps the actions the policy can promote to `primary`. When the
policy promoted one of them - Pull behind origin, Push ahead of it, the merge
and PR actions - it rendered twice, under the same React key.

Dedupe by id in `actionsWithoutPrimary` so the split button and the command
center share one rule, cover it in policy.test.ts against real buildGitActions
output, and pin the rendered menu in a compact Changes browser spec.

(cherry picked from commit 9bb26cc471)

Co-authored-by: az33zy <10743275+az33zy@users.noreply.github.com>
2026-10-19 08:54:23 +02:00
Mohamed Boudra cafe4b01c8 chore(release): cut 0.12.0 2026-10-19 08:45:41 +02:00
Mohamed Boudra 8dd3e2e34e docs(changelog): prepare 0.12.0 stable 2026-10-19 08:41:01 +02:00
paseo-bot[bot] b8bcd204a9 Dismiss touch composer focus when switching tabs (#7352)
* fix(app): dismiss touch composer focus when switching tabs

A retained tab can hide and return before Chromium paints, preserving the focused textarea without another focus call. Blur web touch editors when their pane becomes inactive so returning leaves the conversation ready to read. Exercise the same mounted editor through a second edit and keyboard navigation cycle.

* test(app): reproduce touch tab focus race before paint

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 08:15:20 +02:00
Mohamed Boudra d61dcaf1df fix(app): give subagent rows room on compact and standard press feedback (#7364)
The subagent track rows were as dense on a phone as in the desktop popover,
and the archive and detach actions were hand-rolled pressables that only
recoloured the glyph, so a tap or hover never showed the rounded highlight
every other icon action has.

Rows now take the compact menu-row height and padding below the `md`
breakpoint, decided once in the track row style; the desktop popover keeps
its density. The actions are `ToolbarButton`, which owns the hitbox, the
rounded highlight on hover and press, the compact size, and the tooltip.

A repo oxlint rule, `paseo/no-raw-pressable`, reports `Pressable` and
`Touchable*` imports outside `components/ui/` so the next tap target reaches
for a primitive. Existing importers are on a burn-down list they can only
leave. The rule is its own name rather than another `no-restricted-imports`
entry so its override cannot interact with the existing burn-downs. Its test
runs the real oxlint binary and is wired into the CI quality job.
2026-10-19 12:24:14 +07:00
paseo-bot[bot] 644d66ba4f Select complete Korean terminal words on long press (#7353)
* fix(terminal): select complete Korean words on long press

Wide-character continuation cells carry a blank character but occupy no extra glyph. Treat only actual cells as word separators so either half selects the complete word while real spaces still delimit it.

* fix(terminal): preserve selected words until a drag begins

An unmoved touch release contracted the word selected by the long-press timer. Only extend selection after crossing the existing drag threshold. The Android regression checks the copied whole word after the finger lifts.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 07:03:24 +02:00
Mohamed Boudra 23e388b054 test(app): stabilize desktop overview tool-call expansion (#7359)
Hold the stream through the click so autoscroll cannot move the group between pointer-down and pointer-up. Resume after expansion and verify that inline calls keep updating.
2026-10-19 10:44:35 +07:00
Mohamed Boudra 66348a5e5c fix(app): exclude sentence punctuation from bare Markdown links (#7361)
The shared MD4C permissive autolink scanner consumed trailing path periods and rejected colon boundaries. Fix recognition in the dependency patch and rebuild its web parser so native and web agree. Preserve balanced path parentheses and explicit destinations.
2026-10-19 10:44:31 +07:00
Mohamed Boudra b50acc9344 fix(app): restore assistant text selection in iPhone Safari (#7360)
The Markdown DOM container bypasses React Native Web style prefixing. Safari inherits the gesture host selection suppression unless the selectable boundary also sets WebkitUserSelect. Keep container styling in the existing style configuration so its selection contract is covered by the renderer tests.
2026-10-19 10:44:28 +07:00
Mohamed Boudra 727d5eed59 fix(protocol): preserve the published tool detail import (#7357)
The presentation module move removed a subpath shipped in 0.11.4. Forward its original runtime export and types so plugins retain their imports when upgrading to 0.12.
2026-10-19 10:01:18 +07:00
paseo-bot[bot] a0790e7527 fix(server): collect large file observer benchmark batches (#7349)
Startup inventories exceed the function argument limit when spread into push. Append each event individually in both benchmark collectors so large fixtures can reach their measurement summary.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 04:58:10 +02:00
paseo-bot[bot] be0378ce63 fix(usage): keep distinct Cursor accounts in separate cards (#7344)
Cursor discovery used one constant account key across all credential stores. Derive that key from the token subject in both discovery paths, and isolate unreadable identities by store, so host merging preserves each account while shared accounts still deduplicate.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 03:55:30 +02:00
paseo-bot[bot]andAling33 f0acc2c72b Send MCP terminal keys in the running program’s input mode (#7343)
* fix(server): 正确编码 MCP 终端按键

send_terminal_keys 会将方向键、导航键和多数修饰键组合当作普通文字输入。
复用协议层编码器,并从终端前导序列恢复输入模式,保留 literal 和未知文本的原样发送行为。

补充 MCP 入口回归与真实 PTY 字节验证;MCP 文件 197 项测试通过,
最终终端回归 44 项通过,服务端类型、Lint 和格式检查通过。

Fixes #7337

* fix(terminal): keep MCP keys in the running program input mode

The worker mirror refreshed its input preamble only on snapshot reads, so MCP arrows used normal mode after an unattached program enabled application cursor keys. Carry the authoritative preamble with streamed worker messages before notifying consumers.

Co-authored-by: Aling33 <3489986145@qq.com>

---------

Co-authored-by: Aling33 <3489986145@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 01:53:55 +02:00
559c0200e0 Reuse workspace and subagent lists while replies stream (#7342)
* perf(app): skip workspace agent visibility rebuild on stream ticks

The workspace screen derived agent visibility on every session-store
update: it copied every agent and agent detail into a new Map, walked
all agents, and allocated two Sets, then compared them. Stream ticks
update the store but leave the agent maps untouched, so this was pure
waste at stream rate.

createWorkspaceAgentVisibilitySelector returns the previous result until
session.agents or session.agentDetails changes, and keeps it when a
recompute yields the same sets. Parent lookups read the two maps
directly instead of merging them.

Fixes #4817

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

* perf(app): skip subagent row rebuild on stream ticks

Every mounted agent panel selected its subagent rows by walking all
session agents, allocating rows, sorting, and deep-comparing on every
session-store update, including every stream tick.

createSubagentsForParentSelector returns the previous rows until
session.agents changes, and keeps them when a recompute yields equal
rows. The hook rebuilds the selector when the parent or the pending
archive ids change.

Refs #4817

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

* perf(app): check pending submissions without allocating

selectAgentTurnPresentation runs from several session-store selectors
and filtered the submission list only to read its length. Use some().

Refs #4817

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

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 01:05:05 +02:00
paseo-bot[bot]andStephen Holiday dc7bed417a Keep late composer pills clear of completed turn actions (#7341)
* fix(app): keep composer tracks clear of timeline footers

* fix(app): preserve detached scroll on overlay mount

* style(app): use object input for overlay anchor policy

---------

Co-authored-by: Stephen Holiday <stephen.holiday@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 00:08:37 +02:00
paseo-bot[bot]andHemanth 08853039fb fix(server): keep resumed goal turns visible and stoppable (#7339)
A Codex goal can start while thread/resume is still connecting, before the manager can subscribe. Retain those startup events until the first subscriber and apply them before registration returns, preserving run state and cancellation ownership.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Hemanth <git@soni.im>
2026-10-18 21:55:29 +02:00
paseo-bot[bot] aef7cc36a0 fix(server): connect to relays over high-latency networks (#7336)
The runtime default cancels a pending IPv4 connect before slow routes complete, then fails immediately on unreachable IPv6. Give each relay address five seconds within the existing ten-second handshake deadline, covering both control and data sockets.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 20:57:22 +02:00
7c7b6555f1 Show provider artwork in the catalog and provider rows (#7330)
* Add missing monochrome provider icons (kiro, devin, hermes, minimax-code, traecli, grok, deepseek-tui)

* fix(app): add Kiro catalog icon and Antigravity builtin icon

The ACP catalog's kiro entry had iconId null and no vendored svg, and
the native Antigravity provider resolved to the Bot fallback before the
daemon snapshot arrived. Vendor the Kiro brand mark, wire iconId, and
register a builtin AntigravityIcon matching the plugin's theme-aware
icon.svg.

* fix(app): keep Kiro eyes in monochrome icon

* test(providers): cover catalog artwork and snapshot-free icons

Lock down the missing provider marks through the catalog and the shared icon hook. Exercise the rendered catalog artwork in Chromium alongside coverage for the pre-snapshot fallback.

Co-authored-by: kevenhu001-cyber <keven.hu001@gmail.com>
Co-authored-by: Lu <jack1125516912@outlook.com>

* fix(website): preserve unrelated agent directory artwork

Drop the unrelated website mark introduced with the provider artwork. Cover the existing directory fallback and retained provider mark in a browser regression.

---------

Co-authored-by: kevenhu001-cyber <keven.hu001@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Lu <jack1125516912@outlook.com>
2026-10-18 20:00:36 +02:00
303cebec06 Keep sidebar setup actions inside the empty-state card (#7312)
* fix(app): wrap sidebar empty-state actions so longer labels stay inside the card

The "Add project" / "Import session" row in the sidebar's empty state is a
non-wrapping flex row. With longer translations (e.g. Russian) the second
button overflows the card at the default 320px sidebar width.

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

* test(app): cover sidebar empty-state actions staying inside the card

Renders the empty state in a real browser at the default sidebar width and
checks both actions' bounds against the card, for Russian (fails without the
wrap) and English.

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

* fix(app): keep long sidebar actions inside narrow cards

Wrapping the row still lets a single translated action exceed its available width. Bound each empty-state action to the row and allow its label to shrink and wrap.

Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>

---------

Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 17:54:51 +02:00
paseo-bot[bot] fcda2f1cf0 fix(hub): reconnect after silent connection loss (#7315)
A network path can swallow close notifications while the Hub socket remains open locally. Probe it with protocol ping/pong frames and feed stale sockets into the existing reconnect path.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 17:00:13 +02:00
Mohamed Boudra f7a0e2cbea test(server): wait up to ten seconds for the OMP relaunch to register MCP tools (#7304)
The crash-relaunch test waited with vi.waitFor's default one second for a
fresh runtime and MCP child to come up. On the Windows runner with three
workers that took longer once in six runs and failed main. The assertions
are unchanged; only the budget for the relaunch is realistic now.
2026-10-18 21:11:14 +07:00
paseo-ai[bot] 2a9e86b542 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-18 11:09:08 +00:00
paseo-bot[bot]andMichael Whitman 9ed4f1069b fix(app): create project workspaces on the active host (#7308)
Grouped sidebar projects chose the first eligible host even while a workspace on another host was active. Prefer that active host when online and eligible, keeping the existing fallback for other placements.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Whitman <michael.whitman@getsteward.ai>
2026-10-18 12:57:54 +02:00
Mohamed Boudra cc748dfe12 chore(release): cut 0.12.0-beta.3 2026-10-18 12:47:46 +02:00
Mohamed Boudra 38a8e60780 docs(changelog): 0.12.0-beta.3 2026-10-18 12:43:26 +02:00
paseo-bot[bot]andIvan Malison 60fa12c4de Report disconnected hosts in new agent drafts (#7303)
* fix(app): report offline hosts in new agent drafts

A disconnected client still exists, and pending form resolution and retained loading catalogs outlive the connection. Use host connection state for draft readiness and model loading while keeping known models available for reconnect.

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

* test(app): confirm draft recovery on a fresh host connection

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-10-18 11:57:41 +02:00
Mohamed Boudra 233997ed5c Give Markdown blocks one consistent gap, roomier on desktop, and a zoom-in cursor on images (#7298)
* fix(scripts): recover installs when a dependency patch changed

patch-package cannot apply a patch over a package patched with an earlier
version of it, and without --error-on-fail it exits 0 locally. Checkouts kept
the stale patch silently: the Markdown code block spacing fix never reached
them. The script now fails the install and removes that package so the next
npm install reinstalls it clean with the current patch. A patch-package that
cannot start fails the install without removing anything.

* fix(app): give Markdown blocks one gap and roomier desktop spacing

Every Markdown block is followed by one gap, density.blockGap: 12 on compact
layouts and 16 wider. The library's element styles and the views stacked
outside it (Mermaid segments on native, streaming head and tail) take the same
token, so no block type carries its own margins. Desktop was too tight at 12.

Markdown images show a zoom-in cursor on web, since tapping opens the lightbox.
2026-10-18 15:12:28 +07:00
Mohamed Boudra 3882b957a6 ci: rebalance Playwright shard weights from measured step minutes (#7297)
Weights derived from the Playwright step minutes of five eight-shard runs; shard 8 drops from about 16 minutes to 10 and the shards sit within about 4 minutes of each other once normalized for runner CPU.
2026-10-18 14:56:39 +07:00
paseo-bot[bot]andIvan Malison 095dd0316e fix(app): avoid pane rerenders while resizing Explorer (#7284)
Keep drag widths out of the shared stylesheet and skip unchanged panes during width previews. Preserve the existing resize state and persistence behavior.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <1246619+colonelpanic8@users.noreply.github.com>
2026-10-18 09:25:57 +02:00
Mohamed Boudra a65478d0bf Rank Command Center results by match then recency, with highlights, timestamps, and project icons (#7277)
* feat(app): rank, highlight, and timestamp Command Center results

Workspace and agent rows now show when they were last active, rank by
match quality and then recency, mark the query terms in every result
kind, and lead workspaces with their project icon.

Ties used to be broken by where the match landed in the string, so
"Deliver sidebar usage" beat "expose usage data" on sentence position
and recency almost never decided anything. Built-in rows now compare
only which fields matched and the matcher tier, then the most recent
row wins. The empty query follows the same rule, so it lists the most
recent workspaces and agents instead of alphabetical workspaces and
status-first agents.

Row and section heights live in list-metrics: a layout picks one row
inset and every height derives from it plus shared line boxes, which
the styles also read. The layout is chosen once at the top, so rows
never branch on it, and two-line rows are now vertically centered.
Desktop is denser (32/46); compact keeps 44pt touch rows (44/58).

findHighlightRanges takes the matcher's options so the marks match
what the list matched: the Command Center is not typo-tolerant, the
history list is.

* fix(app): keep the project icon out of Command Center row names

The fallback project icon draws the project's initial as text, which
became the first word of the row's accessible name ("C Command Center
..."), so screen readers announced it. The icon is decorative.

The scroll spec now reads the newest seeded workspace: the empty query
lists the most recent workspaces, so the first one seeded is no longer
among them.
2026-10-18 14:24:14 +07:00
Mohamed Boudra fc12843cef feat(app): show each subagent's model and effort under its title (#7290)
Paseo subagent rows in the subagents list had an empty info strip: the row
only rendered the daemon-provided subtitle, which only provider subagents
carry. The row now uses the same facts line an agent's sheet shows when it
opens without its composer, so both surfaces resolve model, thinking, mode,
and context the way the composer's controls do.

The facts sit under the title instead of beside it, so a long task name
keeps the row's width instead of fighting the strip for it.
2026-10-18 14:16:28 +07:00
Mohamed Boudra a5c566f807 Make full CI finish sooner: eight browser shards, split Windows server job, cancel superseded runs (#7275)
Eight Playwright shards with two workers each, format/lint/relay/sdk combined into one routed quality job, Windows server integration in its own job, cancel-in-progress on every ref, one vitest start for the filtered integration files, no fan-in gate, and a deadline for the heap-bound directory search test that covers its 27,000-directory fixture on Windows.

First run on the Team plan: 19m48s from creation to last job with zero queue, all 21 jobs green, 224 runner-minutes. The same workflow under the old 20-job cap finished in 58 minutes with up to 40 minutes of queue per job.
2026-10-18 13:52:54 +07:00
Mohamed Boudra 9521ee9955 feat(app): label Paseo tool calls verb-first with their context (#7283)
Paseo tool rows put the variable part first ("QA messenger finished"), so
truncation hid the fixed part, and MCP calls showed only the humanized tool
name with no context. Every Paseo tool now has one catalog entry in
protocol/paseo-tool-presentation owning its fixed label, the input field
shown as context, and its detail layout. Agent-message events read
noun-first ("Agent finished" + sender) and use the Paseo icon; the app
ignored it before because the wire detail's legacy "bot" icon won.

Summaries are capped in code at 120 characters because prompts can be one
enormous line; the renderer is not trusted to clip them.

The activity curator behind `paseo logs` and get_agent_activity printed a
bare "[Agent message]" because the server tool-call type never declared the
agentMessage field the projection sets; it now prints the event, sender,
and body.
2026-10-18 13:10:09 +07:00
Mohamed Boudra bd5061e7bd fix(app): stop blank rows while scrolling tall Android chats (#7278)
Two causes, both reproduced on a clone of a real long thinking-partner chat
(replies and thinking rows several screens tall, GFM tables).

1. The window lowered in e42771970 (initial 2 / batch 2 / window 5) holds only
   two or three tall rows, so a fling outruns the mounted range and the viewport
   crosses unmounted rows: 65 blank frames over a 4,152-frame traversal. Keep the
   small initial render that bounds cold-open measurement, but refill a window of
   10 in batches of 10: 0 blank frames over 4,347, p95 25 ms.

2. Scroll-to-bottom from deep history animated across every row in between,
   which no finite window can mount in time: 9 blank frames (~150 ms) per jump.
   Animate only when the current offset is within the bottom-side overscan,
   otherwise jump instantly. Near-bottom jumps still animate. Web already does
   not animate this jump.

Cold open on the same chat is unchanged (first content 569 -> 568 ms); the
deeper window costs first-scroll acknowledgement (2.58 -> 2.94 s in a debug
build).
2026-10-18 13:05:33 +07:00
Mohamed Boudra df7f1f0b75 Restore Markdown link hover underline and spacing after code blocks on web (#7276)
* fix(markdown): restore link hover underline and code block spacing on web

The web renderer swap dropped both. Links: the library styles anchors
inline with text-decoration: none, so the link renderer has to track hover
itself; a stylesheet :hover rule cannot win. Spacing: the patched
renderers.codeBlock override returned the custom block bare, so it lost
the library's codeBlock margins and whatever followed sat flush against
it. The override now keeps those margins, including the zeroed trailing
margin of the last block, so web and native share one spacing config.

* test(desktop): expect Find to return focus to the chat viewport

#7263 moved focus on closing Find from the no-drag focus scope to the
scroll viewport so Page Up and Page Down keep working. The viewport emits
no window region by design (#5233), so the old assertion that the focused
element is no-drag failed on every run. Assert the new focus target and
re-check that the titlebar still has no drag exclusions.
2026-10-18 12:48:10 +07:00
Mohamed Boudra cf62b00911 Keep last successful usage visible when refresh fails (#7246)
* fix(usage): retain last successful data when polls fail

Keep the latest poll outcome separate from its successful snapshot so errors remain visible without discarding known usage or advancing its freshness. Preserve attempt timestamps for response ordering and isolate retained values by scoped login set.

* fix(usage): preserve history through discovery failures

A failed account-store read does not confirm that an account was removed. Retain its existing host mapping until successful discovery replaces it, without carrying mappings across agent launch environments.
2026-10-18 11:06:31 +07:00
paseo-bot[bot]andSimone Primarosa efd6d95f91 fix(plugins): return unavailable when steering provider without prompt.steer capability (#7270)
Co-authored-by: Simone Primarosa <simone.primarosa@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 06:06:08 +02:00
paseo-bot[bot] 1a6a9fa8d0 fix(claude): keep background child action logs after the parent finishes (#7266)
Late child frames were discarded after the launching turn ended, and parent completion cleared accumulated child actions. Keep card updates attached to the launching turn and release child actions on task completion.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 05:02:33 +02:00
paseo-bot[bot] a7a21d91c2 Keep overlapping directory searches from exhausting the heap (#7265)
* fix(server): keep overlapping directory searches within their scan budget

Construct child paths and branch iterators as the round-robin walk consumes them. Eager sibling expansion retained hundreds of thousands of unvisited children per search and exhausted the heap when requests overlapped.

* test(server): stop directory-search children before fixture cleanup

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 03:52:15 +02:00
paseo-bot[bot] b2da58b5bf fix(app): keep chat keyboard scrolling after closing Find (#7263)
Closing Find focused its non-scrolling wrapper, leaving Page Up and Page Down unable to move the feed. Return focus through the viewport handle while preserving the reading position.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 01:51:49 +02:00
paseo-bot[bot] e5d9307cf9 fix(terminal): copy wide characters selected from either half (#7260)
Include the leading cell when a selection starts on a wide character continuation, matching the native renderer highlight. Keep continuation cells out of copied text so real spaces remain unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 00:55:08 +02:00
paseo-bot[bot] 90c6de0580 fix(app): keep streaming replies complete after reloading (#7256)
A catch-up projection could replace the live reply while leaving its cached prefix in the committed rows. Completion then discarded the complete reply as a duplicate. Reconcile the covered cached prefix together with the live reply.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 00:01:33 +02:00
paseo-bot[bot]andhelloimdevman 9bd58f66b2 Copy Korean terminal text without extra spaces (#7254)
* fix(terminal): preserve Korean text when copying native terminal cells

* fix(terminal): omit continuation cells from copied text

Native terminal buffers already retain authoritative cell widths. Skip zero-width continuation cells when serializing a selection so Korean text copies with only its real spaces, while preserving the buffer representation.

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>

---------

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 22:57:04 +02:00
paseo-bot[bot]anda-grasso e2ad26d238 Find source-message text in plugin timeline rows (#7253)
* fix(app): find text in rows a timeline plugin draws

Chat find located a replaced message by its source id but counted matches
only inside elements marked as message text, which a plugin renderer never
sets, so a replaced prompt or reply reported no matches. Mark plugin rows and
search the whole row, with each view bounding a match.

* docs(plugins): qualify search in replacement timeline rows

Chat find discovers source-message matches before inspecting a replacement renderer. Document that renderer-added text is not independently indexed.

Co-authored-by: a-grasso <antonino.grasso@device-insight.com>

* fix(app): find visible text within contiguous plugin blocks

A whole-row walk counted closed popover text and reunited text before and after nested blocks. Keep consecutive block groups and exclude plugin text the browser is not drawing.

---------

Co-authored-by: a-grasso <antonino.grasso@device-insight.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 21:58:07 +02:00
paseo-ai[bot] 142cdac8d2 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-17 18:18:08 +00:00
Mohamed Boudra c517d07d6f chore(release): cut 0.12.0-beta.2 2026-10-17 20:07:37 +02:00
Mohamed Boudra 6388c466b1 docs(changelog): 0.12.0-beta.2 2026-10-17 20:03:33 +02:00
Mohamed Boudra 3c48ac3ad2 Restore inline-code file path links (#7251)
* fix(app): restore inline-code file path links

Classify complete inline-code spans with the file-link classifier before adapting them to the Markdown engine recognition pattern. Keep bare-text heuristics separate so route punctuation and line fragments work without adding prose false positives.

* perf(app): avoid parsing inline-link source on every reveal

Collect candidate values between equal-length backtick runs and let the Markdown engine own code boundaries. This preserves classifier decisions without reparsing long fenced blocks in JavaScript during streaming.
2026-10-18 00:30:19 +07:00
545 changed files with 25321 additions and 14102 deletions
+167 -95
View File
@@ -8,9 +8,13 @@ on:
merge_group:
workflow_dispatch:
# A newer push to the same ref cancels the run still in progress, main included.
# A full run costs about 200 runner-minutes against the org's 20 concurrent
# jobs, and a superseded main run only answers a question the newer commit
# answers again.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: true
env:
# CI does not use the CUDA execution provider, and the onnxruntime-node
@@ -54,10 +58,10 @@ jobs:
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
format:
name: format
quality:
name: quality
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false' || needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -68,36 +72,67 @@ jobs:
cache: "npm"
- name: Install dependencies
id: install
run: node scripts/npm-retry.mjs ci
# Explicit status checks keep independent domains running after a failure.
# A failed step still fails quality; no continue-on-error or success shim.
- name: Check formatting
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
run: npm run format:check
lint:
name: lint
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Lint lockfile
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
- name: Verify dependency signatures
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: npm audit signatures
- name: Lint
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: npm run lint
- name: Test lint rules
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: node --test scripts/oxlint/paseo-plugin.test.mjs
- name: Build relay
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
run: npm run build:relay
- name: Run relay tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
run: npm run test --workspace=@getpaseo/relay
- name: Build client dependencies
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run build:relay && npm run build:client
- name: Build plugin SDK
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run build:plugin
- name: Run plugin SDK tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run test --workspace=@getpaseo/plugin
- name: Run protocol tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run test --workspace=@getpaseo/protocol
- name: Run client tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run test --workspace=@getpaseo/client
- name: Run highlight tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run test --workspace=@getpaseo/highlight
- name: Typecheck client examples
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run typecheck:examples --workspace=@getpaseo/client
typecheck:
name: typecheck
needs: changes
@@ -114,6 +149,9 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Verify dependency patch recovery
run: node --test scripts/postinstall-patches.test.mjs
- name: Build server stack
run: npm run build:server
@@ -143,43 +181,56 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: ubuntu-latest
steps: &server_test_steps
- uses: actions/checkout@v4
steps:
- &server_capacity
name: Log runner capacity
run: |
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
- &server_checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- &server_node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
- &server_fetch
name: Fetch origin/main (worktree tests)
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
- &server_install
name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
- &server_agent_clis
name: Install agent CLIs for provider tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
- &server_build
name: Build server dependencies
run: npm run build:server-deps
- name: Run server tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/server
env:
env: &server_test_env
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- name: Run built-in plugin tests
- &server_plugin_tests
name: Run built-in plugin tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/builtin-plugins
- name: Run Hub CLI/daemon contract
- &server_hub_contract
name: Run Hub CLI/daemon contract
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
@@ -188,7 +239,41 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: windows-latest
steps: *server_test_steps
steps:
- *server_capacity
- *server_checkout
- *server_node
- *server_fetch
- *server_install
- *server_agent_clis
- *server_build
- name: Run server unit tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test:unit --workspace=@getpaseo/server
env: *server_test_env
- *server_plugin_tests
- *server_hub_contract
# Keep the serial integration chain off the Windows unit critical path.
server-integration-windows:
name: server-integration (windows-latest)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: windows-latest
steps:
- *server_capacity
- *server_checkout
- *server_node
- *server_fetch
- *server_install
- *server_agent_clis
- *server_build
- name: Run server integration tests
run: npm run test:integration --workspace=@getpaseo/server
env: *server_test_env
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
# runner that exercises it. macOS — the platform the reported watcher bug was on —
@@ -332,52 +417,19 @@ jobs:
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
sdk-tests:
name: sdk-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build client dependencies
run: npm run build:relay && npm run build:client
- name: Build plugin SDK
run: npm run build:plugin
- name: Run plugin SDK tests
run: npm run test --workspace=@getpaseo/plugin
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
- name: Run client tests
run: npm run test --workspace=@getpaseo/client
- name: Run highlight tests
run: npm run test --workspace=@getpaseo/highlight
- name: Typecheck client examples
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright-1:
name: playwright (shard 1/4)
name: playwright (shard 1/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "1/4"
PLAYWRIGHT_SHARD: "1/8"
PLAYWRIGHT_ARTIFACT: "1"
steps: &playwright_test_steps
- name: Log runner capacity
run: |
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -405,9 +457,11 @@ jobs:
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# Balance contiguous file groups using CI durations and the trimmed
# inventory. Required check names and worker counts stay unchanged.
PWTEST_SHARD_WEIGHTS: "187:183:184:201"
E2E_WORKERS: "2"
# File groups are weighted by observed reporter durations.
# Share of the test list per shard, rebalanced from the Playwright step
# minutes of five eight-shard runs so the slowest and fastest shards meet.
PWTEST_SHARD_WEIGHTS: "85:94:117:128:91:103:127:85"
- name: Upload test artifacts
uses: actions/upload-artifact@v4
@@ -420,56 +474,74 @@ jobs:
retention-days: 7
playwright-2:
name: playwright (shard 2/4)
name: playwright (shard 2/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "2/4"
PLAYWRIGHT_SHARD: "2/8"
PLAYWRIGHT_ARTIFACT: "2"
steps: *playwright_test_steps
playwright-3:
name: playwright (shard 3/4)
name: playwright (shard 3/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "3/4"
PLAYWRIGHT_SHARD: "3/8"
PLAYWRIGHT_ARTIFACT: "3"
steps: *playwright_test_steps
playwright-4:
name: playwright (shard 4/4)
name: playwright (shard 4/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "4/4"
PLAYWRIGHT_SHARD: "4/8"
PLAYWRIGHT_ARTIFACT: "4"
steps: *playwright_test_steps
relay-tests:
name: relay-tests
playwright-5:
name: playwright (shard 5/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
env:
PLAYWRIGHT_SHARD: "5/8"
PLAYWRIGHT_ARTIFACT: "5"
steps: *playwright_test_steps
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
playwright-6:
name: playwright (shard 6/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "6/8"
PLAYWRIGHT_ARTIFACT: "6"
steps: *playwright_test_steps
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
playwright-7:
name: playwright (shard 7/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "7/8"
PLAYWRIGHT_ARTIFACT: "7"
steps: *playwright_test_steps
- name: Build relay
run: npm run build:relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
playwright-8:
name: playwright (shard 8/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "8/8"
PLAYWRIGHT_ARTIFACT: "8"
steps: *playwright_test_steps
cli-tests-1:
name: cli-tests (shard 1/3)
+112
View File
@@ -5,6 +5,7 @@
},
"ignorePatterns": [".dev/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
"categories": {
"correctness": "error",
"suspicious": "error",
@@ -356,6 +357,117 @@
"no-restricted-imports": "off"
}
},
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
"paseo/no-raw-pressable": "error"
}
},
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
// 94 files. They may only leave this list.
{
"files": [
"packages/app/src/components/ui/**/*.{ts,tsx}",
"packages/app/src/**/*.{test,spec}.{ts,tsx}",
"packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx",
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/attachment-pill.tsx",
"packages/app/src/components/combined-model-selector.tsx",
"packages/app/src/components/compact-explorer-sidebar.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/desktop/window-controls.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/file-explorer-pane.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/highlighted-code-block.tsx",
"packages/app/src/components/hosts/host-filter.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/import-session-sheet.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/markdown/fence/mermaid/host.native.tsx",
"packages/app/src/components/markdown/fence/mermaid/host.web.tsx",
"packages/app/src/components/message.tsx",
"packages/app/src/components/model-browser.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
"packages/app/src/components/realtime-voice-overlay.tsx",
"packages/app/src/components/schedules/schedule-row.tsx",
"packages/app/src/components/settings/collapsible-row.tsx",
"packages/app/src/components/settings/headings/settings-info-tip.tsx",
"packages/app/src/components/sidebar-callout.tsx",
"packages/app/src/components/sidebar-resize-handle.tsx",
"packages/app/src/components/sidebar-workspace-list.tsx",
"packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx",
"packages/app/src/components/sidebar/sidebar-header-row.tsx",
"packages/app/src/components/sidebar/sidebar-section-header.tsx",
"packages/app/src/components/sidebar/sidebar-status-list.tsx",
"packages/app/src/components/sidebar/workspace-meta-row/index.tsx",
"packages/app/src/components/terminal-copy-paste-actions.tsx",
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/components/workspace-hover-card.tsx",
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
"packages/app/src/composer/agent-controls/index.tsx",
"packages/app/src/composer/diff-stat-pill.tsx",
"packages/app/src/composer/dock/internal/background.native.tsx",
"packages/app/src/composer/draft/import-pill.tsx",
"packages/app/src/composer/index.tsx",
"packages/app/src/composer/tracks.tsx",
"packages/app/src/desktop/browser/pane/index.electron.tsx",
"packages/app/src/git/actions-split-button.tsx",
"packages/app/src/git/commits-section/commit-row.tsx",
"packages/app/src/git/commits-section/commits-section.tsx",
"packages/app/src/git/diff-pane.tsx",
"packages/app/src/git/forges/gitlab.view.tsx",
"packages/app/src/git/pull-request-panel/checks-section.tsx",
"packages/app/src/git/pull-request-panel/pane.tsx",
"packages/app/src/git/pull-request-panel/section-kit.tsx",
"packages/app/src/hosts/host-chooser.tsx",
"packages/app/src/mobile-panels/presentation.tsx",
"packages/app/src/panels/new-tab-panel.tsx",
"packages/app/src/panels/setup-panel.tsx",
"packages/app/src/plugins/buttons/view.tsx",
"packages/app/src/review/surface.tsx",
"packages/app/src/screens/open-project-screen.tsx",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/projects-screen.tsx",
"packages/app/src/screens/settings-screen.tsx",
"packages/app/src/screens/settings/host-appearance-section.tsx",
"packages/app/src/screens/settings/host-page.tsx",
"packages/app/src/screens/settings/providers-section.tsx",
"packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx",
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
"packages/app/src/usage/sidebar-item.tsx",
"packages/app/src/usage/window-bar.tsx",
"packages/app/src/workspace-labels/manager-modal.tsx",
"packages/app/src/workspace-labels/swatch.tsx",
"packages/app/src/workspace/open-in-editor/button.tsx"
],
"rules": {
"paseo/no-raw-pressable": "off"
}
},
{
"files": ["**/e2e/**/fixtures.ts"],
"rules": {
+43 -5
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.12.0-beta.1 - 2026-10-16
## 0.12.0 - 2026-10-19
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
@@ -27,8 +27,15 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364))
- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
- Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam))
### Plugins
@@ -37,6 +44,10 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR))
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
- Fixed late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday))
### Fixed
@@ -50,16 +61,29 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed the project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308))
- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352))
- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
- Fixed new agent drafts staying on "Loading…" when the host is disconnected; they now say "Host is not connected" and keep your draft ([#7303](https://github.com/getpaseo/paseo/pull/7303) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed reloading a chat during a streaming reply showing a frozen copy above the live one and dropping the ending ([#7256](https://github.com/getpaseo/paseo/pull/7256))
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth))
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
- Fixed OpenCode agents receiving "HTTP 500" instead of a readable error when they call a Paseo tool with invalid arguments or an unknown name ([#7183](https://github.com/getpaseo/paseo/pull/7183))
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
- Fixed background Claude subagent cards staying empty while the subagent works, including after the parent finishes ([#6242](https://github.com/getpaseo/paseo/pull/6242), [#7266](https://github.com/getpaseo/paseo/pull/7266))
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
- Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen))
@@ -80,15 +104,24 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33))
- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
- Fixed long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- Fixed the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315))
- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336))
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed folder links in chats and Markdown previews opening an error tab or doing nothing; they now open Files with the folder's contents visible ([#7205](https://github.com/getpaseo/paseo/pull/7205), [#7210](https://github.com/getpaseo/paseo/pull/7210), [#7229](https://github.com/getpaseo/paseo/pull/7229))
- Fixed Markdown links to files such as `[Capture](capture.png)` doing nothing for some file types ([#7233](https://github.com/getpaseo/paseo/pull/7233))
- Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja))
- Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message or closing Find ([#7033](https://github.com/getpaseo/paseo/pull/7033), [#7263](https://github.com/getpaseo/paseo/pull/7263) by [@panta82](https://github.com/panta82))
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
@@ -108,6 +141,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed the Pi thinking level not updating when a Pi extension changes it ([#6741](https://github.com/getpaseo/paseo/pull/6741))
- Fixed Pi subagents from `@tintinweb/pi-subagents` staying running after their turn limit ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi))
- Fixed manual `/compact` on OMP agents showing no progress and leaving the context meter stale ([#6106](https://github.com/getpaseo/paseo/pull/6106))
- Fixed follow-ups to idle OMP agents staying queued instead of starting the agent ([#7169](https://github.com/getpaseo/paseo/pull/7169) by [@nhthung](https://github.com/nhthung))
- Fixed voice mode TTS rejecting model and voice names from OpenAI-compatible servers ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros))
- Fixed the xhigh thinking option showing "Extra high" instead of its configured label ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj))
- Fixed the Remove button going off screen for long custom model names in provider settings ([#6507](https://github.com/getpaseo/paseo/pull/6507))
@@ -132,7 +166,11 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339))
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
- Fixed Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber))
- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava))
## 0.11.4 - 2026-10-15
+2
View File
@@ -71,6 +71,8 @@ normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
+2 -1
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -249,6 +249,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
+3 -1
View File
@@ -535,7 +535,9 @@ For tighter loops, you can rebuild a single workspace:
`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-package cannot apply a patch that changed after an earlier version was applied, so the
script removes that package and fails the install; the next `npm install` reinstalls it clean
with the current patch. 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.
+42 -16
View File
@@ -11,7 +11,7 @@ contracts.
## Panel host contract
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
Every panel registers its supported `PaneHost` values and presentation. Launchers derive
fixed-target labels and icons from that registration, filter by host, and never substitute one
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
a compatible pane.
@@ -21,7 +21,10 @@ so a pane’s + menu omits each while that pane already contains it. Closing one
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
retention. The shared Explorer owns its tab rail; its shared frame owns visibility, activity and
close-gesture lifetime. Platform adapters own resizing and inset geometry.
The host keeps three recently used tabs plus modified tabs; unmodified tabs beyond that budget
can remount. Native retained content stays live, while web chat uses its existing deferred Freeze.
## Sheets
@@ -40,11 +43,11 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
Close, so the title keeps its width. The sheets present
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
workspace provider an ordinary pane has without a context bridge
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
@@ -56,12 +59,16 @@ the sheet's tab into a pane, and edits the autosave has not started writing are
## Explorer sidebar
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
One Explorer host stays beside the center column across compact overlays and docks. Keep its
providers and gesture roots at the same React position when width or appearance changes. The
workspace route preserves panel ancestry during appearance updates. Appearance-dependent leaves
subscribe to theme changes; the file editor reconfigures its existing editor theme compartment.
Ordinary leaf routes retain the stack appearance policy.
The shell is outside the workspace split canvas so it divides the full workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
never read or modify that width.
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
opens. Compact and wide native layouts select the matching workspace tab in Explorer. Desktop Changes opens
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
@@ -76,18 +83,37 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
hide their close buttons through the panel manifest; close them from the tab context menu.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles.
Web also supports dragging agents and terminals into Explorer.
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
does not change workspace focus.
does not change workspace focus. Web dragging shares the workspace drag scope; compact layouts
accept only reordering inside the source pane.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
lifecycle.
Cmd+E shows or hides Explorer without changing its selected view. All layouts use the same
workspace tabs, launcher, and panel host. Below 600 points the shell is the swipeable Explorer
overlay; at 600 points and above it reserves width beside the workspace. Native docking keeps its
touch resize handle. Full workspace splits remain a separate web-only capability.
The dock reserves at least 240 points and the workspace 360. App navigation yields when it cannot
share the remaining width with both. This keeps a portrait tablet from rendering three unusably
narrow columns; widening restores navigation without changing its saved open state.
Compact panel gestures own overlay visibility; the workspace layout owns its tab selection and
contents on every platform. Explicit pane destinations bypass compact sheet placement, so choosing
a panel in Explorer's + menu keeps it there. Ordinary compact file and subagent look-ups still use
the sheet policy. Resolving an existing main tab reveals center immediately; a new file sheet
keeps Explorer underneath and closing the sheet uncovers it. Semantic navigation resolves the
actual destination before revealing it, including deduplicated targets.
Compact overlay selection and saved dock visibility are independent. Folding changes geometry,
not either mode’s visibility intent. Native workspace retention remains one workspace; web retains
up to ten with its existing inactive TTL.
PR discovery belongs to the active workspace: split layouts discover while active, and other
layouts discover while Explorer is active. The rail requests summary status; selected visible PR
content also requests timeline data. Inactive consumers read cached data without request demand.
Discovery uses ordinary PR tabs and their existing once-per-workspace close opt-out.
## Side pane
+3 -1
View File
@@ -157,7 +157,9 @@ a small `withUnistyles` boundary without subscribing the route tree itself to
every Unistyles runtime update.
Navigators keep their identity across appearance changes. `ThemedStack` remounts
each screen's content below the native stack when appearance tokens change; a
each ordinary screen's content below the native stack when appearance tokens change.
Workspace screens own retained panel lifetimes and update appearance at their
leaves through `screenOwnedAppearance` ([Explorer](explorer-sidebar.md)). A
keyed wrapper above a stack remounts the stack itself, and Android crashes when
that happens while a FragmentManager transaction is running, which settings
hydration at startup makes likely. A screen that mounts a nested navigator (the
+5 -1
View File
@@ -4,6 +4,10 @@ Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat owns paced reveal, the web render cap, message identity and list measurement. Native renders completed messages together. While streaming, it keeps closed blocks in one head view and updates a separate tail, then reunites the text on completion. See [native parsing cost](agent-stream-performance.md#native-parsing-cost) for the boundary and pacing constraints.
## Block spacing
Every block is separated by one gap, `density.blockGap`: 12 on compact layouts, 16 wider. Inside the library's text, every element style ends with that gap and the library drops it after the last block. Views stacked outside the library (Mermaid segments, streaming head and tail) use the same gap through `MarkdownBlockStack`. Do not give a block type its own margins; change the gap.
## Links and images
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
@@ -18,7 +22,7 @@ Math and video are disabled. Task lists and admonitions are enabled; task checkb
Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; see [native selection limitations](native-text-selection.md) for diagram grouping and leading inline images. Streaming keeps the last valid diagram visible while the next revision renders.
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also keeps the library's block spacing around the web code block override, preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
+3 -1
View File
@@ -32,7 +32,9 @@ The UI worklet owns transient motion:
React publishes the active panel only when the canonical target and the UI-thread position agree at
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
Native worklets own overlay opacity and pointer events while
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
release. Native panel hosts and their dependent draggable lists
+27 -9
View File
@@ -240,8 +240,9 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/reference.md#host-ui).
@@ -332,7 +333,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
@@ -400,6 +401,25 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
### Plugin icons
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
`icon` is a Lucide name, `{ svg }`, or a component.
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
The daemon applies it to provider and usage-source icon files; the app applies it to inline
icons. Change the rules there, never at a call site.
- The app validates each icon once, at registration, through `parsePluginIcon` in
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
- That module is the only code that branches on the icon form. Surfaces draw with
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
component icon never reach an SVG element.
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
each attachment, and a component cannot be stored.
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
markup string.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
@@ -491,15 +511,13 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
string is never inline SVG or a URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
+8
View File
@@ -74,6 +74,14 @@ The adapter keys legacy slots by physical socket, so an old connection cannot re
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
for parsing compatibility; modern requests cannot select their subscription ID.
Workspace setup details negotiate `workspace_setup_observation` with
`server_info.features.workspaceSetupObservation`. Capable clients cannot request details through
the legacy session event category. The setup panel owns a workspace-scoped observation while
active and visible; directory metadata signals failures without requesting output. Older clients
retain their legacy event delivery. A new app makes no setup event demand on older hosts and
requires a host update to view details. Subscription-capable older hosts honor that absence of
demand; broadcast-era hosts require an update before wire silence is guaranteed.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
+1 -1
View File
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
+1 -3
View File
@@ -154,8 +154,6 @@ npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
- Extract complex setup into reusable helpers
- Test bodies should read like plain English
- Build a vocabulary of test helpers that make complex flows simple
- Use one browser test per causal user journey, with named `test.step()` blocks for behaviors reached along the way. Reuse the open form, workspace, and browser state. Start another test for a distinct initial state, failure injection, race ordering, viewport, or destructive branch.
- Keep pure test-helper checks in `packages/app/e2e/support/helpers/*.test.ts`; the app unit job runs them without Metro, a daemon, or a browser.
### File naming
@@ -195,7 +193,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. Automated Metro launches set `EXPO_UNSTABLE_HEADLESS=1` so Expo does not download the native DevTools shell into temporary test homes; its read-only cache breaks teardown. Browser fixtures and the standalone Electron browser runner share the developer-toast dismissal helper while retaining console logs. A pointer click moves hover away from the tested control and stalls behind app modal backdrops.
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
+2 -1
View File
@@ -251,7 +251,8 @@ do not imply local ownership or authorization. Capture the sender title at deliv
removed senders must remain understandable without a live directory lookup.
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
presentation (`protocol/paseo-tool-presentation`) owns its heading and expanded details, next to the
labels for every Paseo MCP tool. The daemon
must not construct UI labels or prepend sender labels to the semantic message body. The existing
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
+15
View File
@@ -45,6 +45,21 @@
}
);
checks = forAllSystems (
system:
let
pkgs = pkgsFor system;
packages = self.packages.${system};
license = (builtins.fromJSON (builtins.readFile ./package.json)).license;
in
{
license-metadata =
assert packages.paseo.meta.license.spdxId == license;
assert packages.desktop.meta.license.spdxId == license;
pkgs.runCommand "paseo-license-metadata" { } "touch $out";
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
+1 -1
View File
@@ -257,7 +257,7 @@ buildNpmPackage {
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
license = lib.licenses.asl20;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
+1 -1
View File
@@ -1 +1 @@
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
sha256-7B/Zyo/DaHdd3PVq8Hn1HG+x+wLdZBVxgx/cbtaM/S8=
+1 -1
View File
@@ -148,7 +148,7 @@ buildNpmPackage rec {
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
license = lib.licenses.asl20;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
+6677 -3468
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.12.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -137,16 +137,21 @@
"oxlint-tsgolint": "^0.22.1",
"patch-package": "^8.0.1",
"playwright": "^1.56.1",
"react-dom": "19.2.3",
"typescript": "^5.9.3",
"ws": "^8.20.0"
},
"overrides": {
"@codemirror/language": "6.12.4",
"@codemirror/view": "6.43.6",
"expo": "~57.0.30",
"expo-router": "~57.0.28",
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native-reanimated": "4.3.1",
"react-native-worklets": "0.8.3"
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-worklets": "0.10.1"
}
}
+376
View File
@@ -0,0 +1,376 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1296.0)
aws-sdk-core (3.257.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
rexml (~> 3.4, >= 3.4.2)
aws-sdk-kms (1.132.0)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.233.2)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.3)
cgi (0.5.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.7.20261007)
dotenv (2.8.1)
emoji_regex (3.2.3)
erb (6.0.7)
excon (1.7.3)
logger
faraday (2.15.0)
faraday-net_http (>= 2.0, < 3.6)
json
logger
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.5.0)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
fastimage (2.4.1)
fastlane (2.241.1)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
cgi (~> 0.4)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
erb (>= 4.0, < 7.0)
excon (>= 1.0.0, < 2.0.0)
faraday (~> 2.7)
faraday-cookie_jar (~> 0.0.8)
faraday-follow_redirects (~> 0.3)
faraday-multipart (~> 1.0)
faraday-retry (~> 2.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.99)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.5.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 3.4.0, < 4.0.0)
security (~> 0.3)
simctl (~> 1.6.3)
terminal-notifier (>= 3.1.0, < 4.0.0)
terminal-table (~> 4)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.109.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (1.3.1)
addressable (~> 2.9)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
multi_json (~> 1.11)
representable (~> 3.0)
retriable (>= 3.1, < 5.0)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.69.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.4.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.1)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.1)
googleauth (1.17.5)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.1.6)
domain_name (~> 0.5)
io-console (0.9.4)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.21.2)
jwt (3.3.0)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.2)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
net-http (0.10.0)
net-protocol (>= 0.3.0)
uri (>= 0.11.1)
net-protocol (0.4.0)
timeout
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pp (0.6.4)
prettyprint
prettyprint (0.2.1)
prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
representable (3.3.1)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (4.2.0)
rexml (3.4.4)
rouge (3.28.0)
rubyzip (3.7.0)
security (0.3.0)
signet (0.22.1)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (3.1.0)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
timeout (0.6.1)
trailblazer-option (0.1.2)
tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (3.3.0)
unicode-emoji (~> 4.3)
unicode-emoji (4.3.0)
uri (1.1.2)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-linux-gnu
DEPENDENCIES
fastlane (~> 2.234)
multi_json
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1296.0) sha256=70cc7a44fa4824a9df5652b853dc96aaaf09ec20642cd9d42ed7335a36cc3567
aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
aws-sdk-s3 (1.233.2) sha256=b138737a0de8b02f163ae47a9fc184ac98fc9b90926237c7d7af716a14ea1221
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
excon (1.7.3) sha256=ea13fabcab2b5f5bb64e2952a0ac018bf9882832fe7a58206630be18ba888fa9
faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.241.1) sha256=e8e97f3274d678c74d9e473b98d202621262c40dfce2b43b7a05afde7caa2ea5
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
google-apis-core (1.3.1) sha256=20ce9ee1fde0be87fe30a135dfdbd4b9ee33ba32172794c1f2770e7a45361b2f
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
google-apis-storage_v1 (0.69.0) sha256=ca5faad4a244c609bb434f81884432c449e34b31a87623f7c32b582e464ca7c8
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
google-logging-utils (0.2.1) sha256=73945c3ac6800eba802c26b1744765d5057cc4d89182c7255ee1347b0ca96dc7
googleauth (1.17.5) sha256=92498efc6883630a7e011ce4e667f887e314e588d584410ba18df49f1303f3d7
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
signet (0.22.1) sha256=6e73e9f2bdc2f038a5fdc8a89d248792b1b55e9ca50ee1ba458d8f1d9f0a2151
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
BUNDLED WITH
4.0.23
+1
View File
@@ -171,6 +171,7 @@ export default {
[
"expo-build-properties",
{
ios: { enableSceneSupport: true },
android: {
minSdkVersion: 29,
kotlinVersion: "2.1.20",
@@ -2,48 +2,56 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { openChangesPanel, openChangesTreePanel } from "../support/helpers/workspace-tabs";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
}
test("adds a changed file to the focused chat without replacing its composer draft", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
for (const width of [1400, 390]) {
test(`adds a changed file to the focused chat without replacing its composer draft at ${width}px`, async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await openChangesPanel(page);
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
await page.setViewportSize({ width, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
await expect(agentComposer).toBeFocused();
} finally {
await workspace.cleanup();
}
});
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
if (width < 600) await openChangesTreePanel(page);
else await openChangesPanel(page);
const changedFile = page.getByTestId("diff-file-0-toggle");
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
if (width < 600)
await expect(
page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }),
).toHaveCount(0);
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
if (width >= 600) await expect(agentComposer).toBeFocused();
else await expect(agentComposer).toBeEditable();
} finally {
await workspace.cleanup();
}
});
}
@@ -460,7 +460,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
const target = await seedMockAgentWorkspace({
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
title: "Hidden streaming submission",
model: "e2e-fast-stream",
model: "ten-second-stream",
});
const evictionAgents = await Promise.all(
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
@@ -549,7 +549,7 @@ async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`,
title: "Submission missed running transition",
model: "e2e-fast-stream",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
@@ -700,7 +700,7 @@ async function expectCanonicalOrderWinsAcrossOverlappingClients(
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`,
title: "Cross-client submission order",
model: "e2e-fast-stream",
model: "ten-second-stream",
});
const localPrompt = "Send this after the other client turn.";
const remotePrompt = "Commit this other client turn first.";
@@ -26,7 +26,7 @@ test("agent list age advances and command center says just now for a fresh agent
const agentResult = commandCenter.getByTestId(
`command-center-agent-${getServerId()}:${session.agentId}`,
);
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
"just now",
);
await page.keyboard.press("Escape");
@@ -36,25 +36,15 @@ test.describe("Host agent skills", () => {
page,
skills,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
const custom = skills.available.slice(0, 2);
const cancelledAddition = skills.available[2]!;
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
await test.step("the desktop sheet gives the bundled label its intended inset", async () => {
await expectBundledSkillsInset(page);
});
await chooseCustomSkills(page, custom);
await saveSkillSelection(page);
await expectInstalledSkills(skills, custom);
await expectSavedSelection(skills, { mode: "custom", skills: custom });
await test.step("installation ignores agent config environment overrides", async () => {
await expect(access(`${skills.home}/../ignored-claude-config/skills`)).rejects.toThrow(
"ENOENT",
);
await expect(access(`${skills.home}/../ignored-codex-home/skills`)).rejects.toThrow("ENOENT");
});
await openSkillSelection(page);
await toggleSkill(page, cancelledAddition);
@@ -160,6 +150,23 @@ test.describe("Host agent skills", () => {
await expectInstalledSkills(skills, secondSelection.skills);
await expectSavedSelection(skills, secondSelection);
});
test("ignores agent config environment overrides", async ({ skills }) => {
await skills.install({ mode: "custom", skills: [skills.available[0]!] });
await expect(
access(`${skills.home}/../ignored-claude-config/skills`).then(
() => true,
() => false,
),
).resolves.toBe(false);
await expect(
access(`${skills.home}/../ignored-codex-home/skills`).then(
() => true,
() => false,
),
).resolves.toBe(false);
});
});
function allSkillsCard(page: Parameters<typeof openSkillSelection>[0]) {
@@ -185,14 +192,24 @@ test.describe("Agent skills sheet inset", () => {
await openSkillSelection(page);
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
await expectBundledSkillsInset(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
test.describe("desktop", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test("keeps more space above the bundled skills label than below", async ({ page, skills }) => {
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
});
async function expectBundledSkillsInset(page: Parameters<typeof openSkillSelection>[0]) {
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
}
@@ -1,7 +1,9 @@
import { test } from "../support/fixtures";
import { expect } from "@playwright/test";
import {
findScrollJumps,
expectImageSpaceReserved,
type ScrollFrame,
observeTimelinePages,
openOnlyTimelineTail,
recordUpwardTraversal,
@@ -12,6 +14,179 @@ import {
holdParagraphThroughCompletion,
} from "../support/helpers/timeline-scroll-smoothness";
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
const before: ScrollFrame = {
at: 1000,
scrollTop: 1000,
scrollHeight: 5000,
viewportHeight: 800,
virtualized: true,
loading: false,
rows: [
{ id: "image", top: -150, height: 150 },
{ id: "reading", top: 0, height: 100 },
],
anchor: "reading",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 0,
unmounted: 0,
};
const after: ScrollFrame = {
...before,
at: 1400,
scrollTop: 900,
wheelTotal: 100,
lastWheelAt: 1050,
rows: [
{ id: "image", top: -50, height: 1150 },
{ id: "reading", top: 1100, height: 100 },
],
};
expect(findScrollJumps([before, after])).toEqual([]);
expect(
findScrollJumps([
{ ...before, scrollTop: 200 },
{
...after,
wheelTotal: 1000,
scrollTop: 0,
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
},
]),
).toEqual([]);
expect(
findScrollJumps([
before,
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
]),
).toHaveLength(1);
expect(
findScrollJumps([
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
]),
).toHaveLength(1);
});
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
const before: ScrollFrame = {
at: 1000,
scrollTop: 2000,
scrollHeight: 5000,
viewportHeight: 800,
virtualized: true,
loading: false,
rows: [
{ id: "new-reader", top: -520, height: 278 },
{ id: "image", top: -242, height: 184 },
{ id: "old-reader", top: -58, height: 130 },
],
anchor: "old-reader",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 0,
unmounted: 0,
};
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
const after: ScrollFrame = {
...wheel,
at: 1220,
scrollTop: 1520,
scrollHeight: 5041,
anchor: "new-reader",
rows: [
{ id: "new-reader", top: -40, height: 278 },
{ id: "image", top: 238, height: 225 },
{ id: "old-reader", top: 463, height: 130 },
],
};
expect(findScrollJumps([before, wheel, after])).toEqual([]);
// The previous 480 px input has already moved the viewport; the recent
// budget includes it, but only the next 480 px belongs to this frame.
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
const steadyAfter = {
...after,
at: 1100,
wheelTotal: 960,
lastWheelAt: 1099,
scrollHeight: 6000,
rows: [
{ id: "new-reader", top: -40, height: 278 },
{ id: "image", top: 238, height: 1184 },
{ id: "old-reader", top: 1422, height: 130 },
],
};
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
expect(
findScrollJumps([
before,
wheel,
{
...after,
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
},
]),
).toHaveLength(1);
});
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
for (const height of [225, 1632]) {
const before: ScrollFrame = {
at: 1000,
scrollTop: 2208,
scrollHeight: 5000,
viewportHeight: 516,
virtualized: true,
loading: false,
rows: [
{ id: "image", top: -704, height: 560 },
{ id: "caption", top: -144, height: 100 },
{ id: "old-reader", top: -44, height: 127 },
],
anchor: "old-reader",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 3,
unmounted: 0,
};
const resize = height - 560;
const after: ScrollFrame = {
...before,
at: 1060,
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
scrollTop: 1938,
scrollHeight: 5000 + resize - 110,
wheelTotal: 160,
lastWheelAt: 1020,
imageLoads: 1,
rows: [
{ id: "image", top: -544, height },
{ id: "caption", top: 16 + resize, height: 100 },
{ id: "old-reader", top: 116 + resize, height: 127 },
],
};
expect(findScrollJumps([before, after])).toEqual([]);
// Resizing must not excuse additional viewport movement.
expect(
findScrollJumps([
before,
{
...after,
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
},
]),
).toHaveLength(1);
}
});
for (const cadence of scrollCadences) {
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
page,
+51 -45
View File
@@ -51,9 +51,7 @@ test.describe("Archive tab reconciliation", () => {
await tempRepo?.cleanup();
});
test("non-UI archive prunes open tabs and keeps the session archived when reopened", async ({
page,
}) => {
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
@@ -67,51 +65,28 @@ test.describe("Archive tab reconciliation", () => {
const passivePage = await page.context().newPage();
try {
await test.step("open both sessions in two pages", async () => {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openSessions(page);
await expectSessionRowVisible(page, archived.title);
await expectSessionRowVisible(page, surviving.title);
await openSessions(passivePage);
await expectSessionRowVisible(passivePage, archived.title);
await expectSessionRowVisible(passivePage, surviving.title);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
});
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openSessions(page);
await expectSessionRowVisible(page, archived.title);
await expectSessionRowVisible(page, surviving.title);
await openSessions(passivePage);
await expectSessionRowVisible(passivePage, archived.title);
await expectSessionRowVisible(passivePage, surviving.title);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await archiveAgentFromDaemon(client, archived.id);
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
expect(archivedAt).not.toBeNull();
await test.step("prune the archived tab across both pages and reload", async () => {
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, surviving.workspaceId);
await expectWorkspaceTabHidden(passivePage, archived.id);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await test.step("reopen the archived session without unarchiving it", async () => {
await openSessions(page);
await expectSessionRowArchived(page, archived.title);
await clickSessionRow(page, archived.title);
await expectArchivedAgentFocused(page, archived.id);
await expectAgentTabActive(page, archived.id);
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
timeout: 30_000,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, surviving.workspaceId);
await expectWorkspaceTabHidden(passivePage, archived.id);
} finally {
await passivePage.close();
}
@@ -148,4 +123,35 @@ test.describe("Archive tab reconciliation", () => {
await passivePage.close();
}
});
test("clicking an archived session navigates without unarchiving it", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
});
await resetSeededPageState(page);
await openWorkspaceWithAgents(page, [archived, surviving]);
await archiveAgentFromDaemon(client, archived.id);
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
expect(archivedAt).not.toBeNull();
await openSessions(page);
await expectSessionRowArchived(page, archived.title);
await clickSessionRow(page, archived.title);
await expectArchivedAgentFocused(page, archived.id);
await expectAgentTabActive(page, archived.id);
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
timeout: 30_000,
});
});
});
@@ -90,7 +90,7 @@ test.describe("Assistant fork menu", () => {
await expectLiveAssistantText(page, visibleAfterFork);
});
test("focuses an existing-workspace fork draft and keeps its attachment after submission", async ({
test("focuses a forked assistant turn in a new workspace draft tab", async ({
page,
seedForkWorkspace,
}) => {
@@ -101,44 +101,55 @@ test.describe("Assistant fork menu", () => {
model: "ten-second-stream",
});
await test.step("complete a second assistant turn in the visible source tab", async () => {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
await session.client.waitForFinish(session.agentId, 45_000);
await awaitAssistantMessage(page);
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
await session.client.waitForFinish(session.agentId, 45_000);
await awaitAssistantMessage(page);
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
await expect(agentTab).toHaveAttribute("aria-selected", "true");
await forkMostRecentAssistantTurnToNewTab(page);
const selectedTab = page
.getByTestId("workspace-tabs-row")
.getByRole("button")
.and(page.locator('[aria-selected="true"]'));
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
timeout: 30_000,
});
await expect(agentTab).toHaveAttribute("aria-selected", "false");
await expectChatHistoryAttachment(page);
});
test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-tab-submit-",
title: "Assistant fork tab submit",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab submit.",
model: "ten-second-stream",
});
await test.step("fork the latest turn into the selected draft tab", async () => {
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
await expect(agentTab).toHaveAttribute("aria-selected", "true");
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await forkMostRecentAssistantTurnToNewTab(page);
await forkMostRecentAssistantTurnToNewTab(page);
await expectChatHistoryAttachment(page);
const selectedTab = page
.getByTestId("workspace-tabs-row")
.getByRole("button")
.and(page.locator('[aria-selected="true"]'));
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
timeout: 30_000,
});
await expect(agentTab).toHaveAttribute("aria-selected", "false");
await expectChatHistoryAttachment(page);
});
await submitMessage(page, "");
await test.step("submit that draft with its chat-history attachment", async () => {
await submitMessage(page, "");
const userMessage = page
.getByTestId("user-message")
.filter({ hasText: "Chat history" })
.last();
await expect(userMessage).toBeVisible({ timeout: 30_000 });
await expect(userMessage).not.toContainText("Source agent:");
});
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
await expect(userMessage).toBeVisible({ timeout: 30_000 });
await expect(userMessage).not.toContainText("Source agent:");
});
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
File diff suppressed because it is too large Load Diff
+99 -67
View File
@@ -5,14 +5,13 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
import { seedWorkspace } from "../support/helpers/seed-client";
import { createControlledStream } from "../support/helpers/controlled-stream";
import {
composerLocator,
fillComposerDraft,
@@ -21,18 +20,20 @@ import {
} from "../support/helpers/composer";
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
import {
expectPromptsDrawnByPlugin,
withPromptTimelinePlugin,
} from "../support/helpers/plugin-timeline";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
const STREAMED_TAIL = "STREAMED-REPLY-FINAL-TAIL";
const HYDRATED_PARTIAL = "Hydrated partial continuation";
const STREAMED_PREFIX = [
const STREAMED_RESPONSE = [
"First paragraph with alpha-needle and pair-needle inside.",
"Second paragraph with beta-needle and pair-needle inside.",
`Third paragraph ${FILLER}`,
].map((paragraph) => `${paragraph}\n\n`);
].join("\n\n");
const RESPONSE =
'İ😀hello **world**; hello *world*.\n\n[hello &amp; world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
@@ -124,6 +125,49 @@ async function closeFind(page: Page) {
.toEqual([]);
}
async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") {
// The scrolling surface has no accessible name; use its existing test id.
const viewport = page.getByTestId("agent-chat-scroll");
await waitForTimelineGeometryToSettle(page);
const before = await viewport.evaluate((element) => element.scrollTop);
await page.keyboard.press(key);
const direction = key === "PageUp" ? -1 : 1;
await expect
.poll(
async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before),
)
.toBeGreaterThan(100);
}
test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-scroll-focus-",
title: "Chat Find keyboard scrolling",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await closeFind(page);
await expectChatKeyboardScroll(page, "PageUp");
await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") });
await expectChatKeyboardScroll(page, "PageDown");
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await page.getByRole("button", { name: "Close Find", exact: true }).click();
await expect(query(page)).toHaveCount(0);
await expectChatKeyboardScroll(page, "PageUp");
} finally {
await agent.cleanup();
}
});
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
page,
}, testInfo) => {
@@ -178,7 +222,7 @@ async function highlightedMessage(page: Page) {
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
@@ -223,6 +267,34 @@ test("counts and steps through every match in the chat, not just the selected me
}
});
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-plugin-prompt-",
title: "Chat Find plugin prompt",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await withPromptTimelinePlugin(async () => {
await openAgentRoute(page, agent);
await expectPromptsDrawnByPlugin(page);
await openChatFind(page);
await query(page).fill("scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "plugin-prompt");
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
});
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
@@ -286,90 +358,50 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
}
});
interface StreamedReplyChat {
cleanup(): Promise<void>;
advancePastVisiblePrefix(): Promise<void>;
expectHydratedPartial(): Promise<void>;
interface StreamedReplyChat extends MockAgentWorkspace {
/** Resolves once the streamed reply has finished arriving. */
replyFinished(): Promise<void>;
}
/** The provider owns the pause, so both search and hydration see incomplete output. */
/**
* Opens the chat first, then streams a multi-block reply into it and waits for a
* later block, so the block holding the early needles is no longer the growing row.
*/
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
const stream = await createControlledStream();
const workspace = await seedWorkspace({ repoPrefix: "chat-find-live-blocks-" }).catch(
async (error) => {
await stream.cleanup();
throw error;
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-live-blocks-",
title: "Chat Find live blocks",
featureValues: {
mockStreamingAssistantResponse: STREAMED_RESPONSE,
mockStreamingAssistantIntervalMs: 60,
},
);
const gate = await installDaemonWebSocketGate(page);
const cleanup = async () => {
try {
await stream.cleanup();
} finally {
await workspace.cleanup();
}
};
});
try {
const agent = await workspace.client.createAgent({
provider: stream.provider,
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Chat Find live blocks",
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
timeout: 30_000,
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await workspace.client.sendAgentMessage(agent.id, "Stream a multi block answer");
await stream.append(STREAMED_PREFIX);
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible();
return {
cleanup,
advancePastVisiblePrefix: async () => {
const count = gate.getAgentStreamItemCount("assistant_message");
gate.setAgentStreamSuppressed(true);
await stream.append([HYDRATED_PARTIAL]);
await gate.waitForAgentStreamItem("assistant_message", count + 1);
await expect(page.getByTestId("assistant-message").last()).not.toContainText(
HYDRATED_PARTIAL,
);
},
expectHydratedPartial: async () => {
// A newer daemon row forces Find to fetch the real timeline instead of
// matching only the browser's streamed prefix.
await expect(page.getByTestId("assistant-message").last()).toContainText(HYDRATED_PARTIAL);
expect(gate.getTimelineRequestCount("before", agent.id)).toBeGreaterThan(0);
},
...agent,
replyFinished: async () => {
gate.setAgentStreamSuppressed(false);
await stream.finish([`\n\n${STREAMED_TAIL}`]);
await workspace.client.waitForFinish(agent.id, 15_000);
await agent.client.waitForFinish(agent.agentId, 60_000);
},
};
} catch (error) {
await cleanup();
await agent.cleanup();
throw error;
}
}
async function expectIncompleteStreamedReply(page: Page): Promise<void> {
await expect(page.getByTestId("assistant-message").last()).toContainText("beta-needle");
await expect(page.getByTestId("assistant-message").last()).toContainText("filler399");
await expect(page.getByTestId("assistant-message").last()).not.toContainText(STREAMED_TAIL);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
}
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
test.setTimeout(120_000);
const chat = await openChatStreamingMultiBlockReply(page);
try {
await expectIncompleteStreamedReply(page);
await chat.advancePastVisiblePrefix();
await openChatFind(page);
// The first-paragraph hit must remain searchable while the same message grows.
await expectFindStatus(page, "alpha-needle", "1 of 1");
await chat.expectHydratedPartial();
await expectIncompleteStreamedReply(page);
await chat.replyFinished();
await expect(page.getByTestId("assistant-message").last()).toContainText(STREAMED_TAIL);
await expectFindStatus(page, "beta-needle", "1 of 1");
// One message, two paragraphs, one count across both.
await expectFindStatus(page, "pair-needle", "1 of 2");
+57 -44
View File
@@ -1,4 +1,4 @@
import { expect, test, type Page } from "../support/fixtures";
import { expect, test } from "../support/fixtures";
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
import {
clickChatOutlineRowEdge,
@@ -50,43 +50,6 @@ import {
const WIDE_VIEWPORT = { width: 1440, height: 900 };
const LOADED_TURNS = 16;
async function navigateLoadedOutline(page: Page, agent: LongTimelineAgent): Promise<void> {
await test.step("preview across the row", async () => {
await expectOneActiveChatOutlinePrompt(page);
await hoverChatOutlinePrompt(page, 2);
await expectChatOutlinePreview(page, agent.prompts[1]);
await pointAtChatOutlineRowEdge(page, 7);
await expectChatOutlinePreview(page, agent.prompts[6]);
await movePointerOffChatOutline(page);
await expectNoChatOutlinePreview(page);
});
await test.step("jump, dismiss preview, and retain bare selection", async () => {
await clickChatOutlineRowEdge(page, 4);
await expectChatOutlinePromptToRemainBare(page, 4);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
await expectActiveChatOutlinePrompt(page, 4);
await movePointerOffChatOutline(page);
await expectNoChatOutlinePreview(page);
});
await test.step("track the active prompt while jumping and scrolling", async () => {
await expectOneActiveChatOutlinePrompt(page);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectActiveChatOutlinePromptMovedFrom(page, 4);
});
await test.step("clamp the newest prompt at maximum scroll", async () => {
await clickChatOutlineRowEdge(page, LOADED_TURNS);
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
});
}
async function jumpToNewerLoadedPrompt(page: Page, agent: LongTimelineAgent): Promise<void> {
await clickChatOutlineRowEdge(page, 60);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
}
test.describe("desktop chat outline", () => {
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
page,
@@ -220,14 +183,28 @@ test.describe("desktop chat outline", () => {
await expectChatOutlinePrompts(page, LOADED_TURNS);
});
test("previews, jumps, and tracks the reader through loaded prompts", async ({ page }) => {
await navigateLoadedOutline(page, agent);
test("previews the prompt under the pointer, anywhere across its row", async ({ page }) => {
await hoverChatOutlinePrompt(page, 2);
await expectChatOutlinePreview(page, agent.prompts[1]);
await pointAtChatOutlineRowEdge(page, 7);
await expectChatOutlinePreview(page, agent.prompts[6]);
await movePointerOffChatOutline(page);
await expectNoChatOutlinePreview(page);
});
test("does not preview while crossing the rail toward the sidebar", async ({ page }) => {
await expectNoChatOutlinePreviewWhileCrossingToSidebar(page);
});
test("closes the preview when the pointer leaves after a jump", async ({ page }) => {
await clickChatOutlineRowEdge(page, 4);
await movePointerOffChatOutline(page);
await expectNoChatOutlinePreview(page);
});
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
await focusChatOutlinePrompt(page, 1);
await expectChatOutlinePreview(page, agent.prompts[0]);
@@ -238,6 +215,32 @@ test.describe("desktop chat outline", () => {
await expectActiveChatOutlinePrompt(page, 1);
});
test("moves the active mark as the reader jumps and scrolls", async ({ page }) => {
await expectOneActiveChatOutlinePrompt(page);
await clickChatOutlineRowEdge(page, 4);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
await expectActiveChatOutlinePrompt(page, 4);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectActiveChatOutlinePromptMovedFrom(page, 4);
});
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
await clickChatOutlineRowEdge(page, 4);
await expectChatOutlinePromptToRemainBare(page, 4);
});
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
page,
}) => {
await clickChatOutlineRowEdge(page, LOADED_TURNS);
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
});
test("keeps the current prompt active throughout a long live assistant turn", async ({
page,
}) => {
@@ -279,11 +282,21 @@ test.describe("desktop chat outline", () => {
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
expect(history.requestCount()).toBe(requestsBeforeJump);
});
await test.step(
"jump downward from old history to a newer loaded prompt",
jumpToNewerLoadedPrompt.bind(null, page, agent),
);
test("pins a newer loaded prompt when jumping downward from old history", async ({ page }) => {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await scrollThroughOlderHistoryPages(page, 2, history);
await scrollTimelineToNewestLoadedEdge(page);
await clickChatOutlineRowEdge(page, 30);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
await clickChatOutlineRowEdge(page, 60);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
});
});
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
openCommandCenterForAgent,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
@@ -41,93 +42,37 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page):
test.describe("Command Center agent controls", () => {
test.describe.configure({ timeout: 180_000 });
test("search, select, and re-rank live agent controls before navigating to an action", async ({
page,
}) => {
test("changes a running agent setting and preserves its selected row", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-live-controls-",
title: "Command Center live controls",
model: "five-minute-stream",
});
try {
// A stale runtime mode makes selecting the provider's supported mode
// exercise the live setting RPC instead of the selected-row no-op.
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
await openAgentRoute(page, workspace);
await test.step("changes a running agent setting and preserves its selected row", async () => {
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "load test",
choice: "Mode › Load test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
await openCommandCenter(page);
await expectCommandCenterAgentControlSelected({
page,
query: "load test",
choice: "Mode › Load test",
});
await closeCommandCenter(page);
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await test.step("matches query tokens in any order", async () => {
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "test load",
choice: "Mode › Load test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "load test",
choice: "Mode › Load test",
});
await test.step("activates the best-matching row, not the highest-ranked group", async () => {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const panel = await openCommandCenter(page);
const input = panel.getByTestId("command-center-input");
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
await test.step("drops an arrow-key selection once the query changes", async () => {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const panel = await openCommandCenter(page);
const input = panel.getByTestId("command-center-input");
await input.fill("loa");
// Moves the highlight onto a model row, which still matches after the next keystroke.
await page.keyboard.press("ArrowDown");
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
});
await test.step("keeps an action first when the action is the best match", async () => {
const panel = await openCommandCenter(page);
const input = panel.getByTestId("command-center-input");
await input.fill("sched");
await page.keyboard.press("Enter");
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
await openCommandCenter(page);
await expectCommandCenterAgentControlSelected({
page,
query: "load test",
choice: "Mode › Load test",
});
} finally {
await workspace.cleanup();
@@ -155,6 +100,107 @@ test.describe("Command Center agent controls", () => {
}
});
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
// model. Asserting both model and mode catches either row winning.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-relevance-",
title: "Command Center relevance",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("matches query tokens in any order", async ({ page }) => {
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
// a substring of it. Only per-token matching finds this row.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-tokenized-",
title: "Command Center tokenized search",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "test load",
choice: "Mode › Load test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("keeps an action first when the action is the best match", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-action-priority-",
title: "Command Center action priority",
model: "five-minute-stream",
});
try {
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("sched");
await page.keyboard.press("Enter");
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
test("drops an arrow-key selection once the query changes", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-active-reset-",
title: "Command Center active reset",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("loa");
// Moves the highlight onto a model row, which still matches after the next keystroke.
await page.keyboard.press("ArrowDown");
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("applies draft model and setting choices to the created agent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
await seedMockDraftPreferences(page);
@@ -82,20 +82,31 @@ test("workspace file search stays geometrically stable through delayed loading a
await row.click();
await expectFileTabOpen(page, FILE_PATH);
await test.step("dropping the files scope leaves the search row the same height", async () => {
await page.keyboard.press("Meta+P");
} finally {
await seeded.cleanup();
}
});
const reopenedPanel = page.getByTestId("command-center-panel");
await expect(reopenedPanel).toBeVisible({ timeout: 30_000 });
const header = reopenedPanel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-file-search-scope-",
title: "Scope chip height",
});
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
try {
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
expect(scoped?.height).toBe(unscoped?.height);
});
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
const header = panel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
expect(scoped?.height).toBe(unscoped?.height);
} finally {
await seeded.cleanup();
}
@@ -0,0 +1,58 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { seedWorkspace } from "../support/helpers/seed-client";
const GROUP_BY_STATUS = "Group by status";
const GROUP_BY_PROJECT = "Group by project";
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("group");
const entry = panel.getByText(label, { exact: true });
await expect(entry).toBeVisible({ timeout: 30_000 });
// The entry always names the mode you are not in, so its opposite must never be listed.
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
await entry.click();
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
}
test.describe("Command center sidebar grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" });
try {
await gotoAppShell(page);
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
const statusList = page.getByTestId("sidebar-status-list-scroll");
await expect(projectList).toBeVisible({ timeout: 30_000 });
// Query-only: the grouping entry stays out of the default empty-query list.
const panel = await openCommandCenter(page);
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(panel).not.toBeVisible({ timeout: 30_000 });
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
await expect(statusList).toBeVisible({ timeout: 30_000 });
await expect(projectList).toHaveCount(0);
// The only assertion that catches a flip which never reached persisted storage.
await page.reload();
await expect(statusList).toBeVisible({ timeout: 30_000 });
// Grouping persists, so the run must leave the sidebar back in project mode.
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
await expect(projectList).toBeVisible({ timeout: 30_000 });
await expect(statusList).toHaveCount(0);
} finally {
await seeded.cleanup().catch(() => undefined);
}
});
});
@@ -45,7 +45,8 @@ test("opening the command center keeps its first result fully visible", async ({
await expectCommandCenterToRemainAtTheTop(page);
await expectPrimaryCommandCenterActions(panel);
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
// The empty query lists the most recent workspaces, so the last one seeded leads it.
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
} finally {
await seeded.cleanup();
@@ -107,122 +107,73 @@ async function rejectNextLabelAssignment(page: Page): Promise<void> {
});
}
const GROUP_BY_STATUS = "Group by status";
const GROUP_BY_PROJECT = "Group by project";
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("group");
const entry = panel.getByText(label, { exact: true });
await expect(entry).toBeVisible({ timeout: 30_000 });
// The entry always names the mode you are not in, so its opposite must never be listed.
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
await entry.click();
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
}
test.describe("Command center workspace management", () => {
test("manage the active workspace and scope its commands when leaving the route", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-manage-" });
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await test.step("renames the active workspace while its sidebar row is unmounted", async () => {
await collapseProjectSection(page, workspace);
await collapseProjectSection(page, workspace);
await runCommand(page, "rename", "Rename workspace");
await runCommand(page, "rename", "Rename workspace");
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
// focus race with its focus-restore, still renders — you just cannot type into it.
const input = page.getByTestId("workspace-rename-modal-global-input");
await expect(input).toBeFocused({ timeout: 15_000 });
await expect(input).toHaveValue(workspace.workspaceName);
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
// focus race with its focus-restore, still renders — you just cannot type into it.
const input = page.getByTestId("workspace-rename-modal-global-input");
await expect(input).toBeFocused({ timeout: 15_000 });
await expect(input).toHaveValue(workspace.workspaceName);
const customTitle = "Renamed From Palette";
await input.fill(customTitle);
await page.getByTestId("workspace-rename-modal-global-submit").click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
const customTitle = "Renamed From Palette";
await input.fill(customTitle);
await page.getByTestId("workspace-rename-modal-global-submit").click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
// Re-expand the section to read the row back.
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: workspace.projectDisplayName });
await header.click();
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
timeout: 15_000,
});
// Re-expand the section to read the row back.
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: workspace.projectDisplayName });
await header.click();
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
timeout: 15_000,
});
await test.step("copies the workspace path and confirms with a toast", async () => {
await runCommand(page, "copy path", "Copy workspace path");
} finally {
await workspace.cleanup();
}
});
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
timeout: 15_000,
});
test("copies the workspace path and confirms with a toast", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runCommand(page, "copy path", "Copy workspace path");
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
timeout: 15_000,
});
await test.step("flips the pin entry to Unpin after pinning from the palette", async () => {
await runCommand(page, "pin", "Pin to top");
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
} finally {
await workspace.cleanup();
}
});
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("pin");
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Pin to top")).toHaveCount(0);
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
await action(panel, "Unpin").click();
await expect(page.getByTestId("sidebar-pinned-section")).toHaveCount(0);
});
await test.step("grouping commands persist across reload and switch back", async () => {
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
const statusList = page.getByTestId("sidebar-status-list-scroll");
await expect(projectList).toBeVisible({ timeout: 30_000 });
// Query-only: the grouping entry stays out of the default empty-query list.
const panel = await openCommandCenter(page);
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(panel).not.toBeVisible({ timeout: 30_000 });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
await expect(statusList).toBeVisible({ timeout: 30_000 });
await expect(projectList).toHaveCount(0);
await runCommand(page, "pin", "Pin to top");
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
// The only assertion that catches a flip which never reached persisted storage.
await page.reload();
await expect(statusList).toBeVisible({ timeout: 30_000 });
// Grouping persists, so the run must leave the sidebar back in project mode.
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
await expect(projectList).toBeVisible({ timeout: 30_000 });
await expect(statusList).toHaveCount(0);
});
await test.step("lists only the globally-handled toggle off a workspace route", async () => {
// All three are listed while a workspace route owns the handlers.
const workspacePanel = await openCommandCenter(page);
await workspacePanel.getByTestId("command-center-input").fill("toggle");
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({
timeout: 15_000,
});
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("toggle");
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
});
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("pin");
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Pin to top")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
@@ -232,6 +183,35 @@ test.describe("Command center workspace management", () => {
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
// off a workspace route would give the user two entries that close the palette and do nothing.
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
// All three are listed while a workspace route owns the handlers.
const workspacePanel = await openCommandCenter(page);
await workspacePanel.getByTestId("command-center-input").fill("toggle");
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("toggle");
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("toggles a label on the active workspace from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
@@ -44,16 +44,6 @@ test.describe("Command center workspaces", () => {
});
await gotoAppShell(page);
await test.step("single-host results show project and branch without a host label", async () => {
const singleHostPanel = await openCommandCenter(page);
const singleHostRow = singleHostPanel.getByTestId(
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
);
await expect(singleHostRow.getByTestId("command-center-workspace-subtitle")).toHaveText(
`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`,
);
await page.keyboard.press("Escape");
});
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
@@ -131,4 +121,38 @@ test.describe("Command center workspaces", () => {
await seeded.cleanup();
}
});
test("single-host workspace subtitle omits the host and shows project · branch", async ({
page,
}) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-workspace-single-",
title: WORKSPACE_TITLE,
});
try {
execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], {
cwd: seeded.repoPath,
stdio: "ignore",
});
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
}
// No secondary host: with a single host, the host label is gated away.
await gotoAppShell(page);
const panel = await openCommandCenter(page);
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
);
await expect(row).toBeVisible({ timeout: 30_000 });
const subtitle = row.getByTestId("command-center-workspace-subtitle");
await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`);
} finally {
await seeded.cleanup();
}
});
});
@@ -0,0 +1,115 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task();
}
});
async function createRemoteRepo(prefix: string): Promise<string> {
const repo = await createTempGitRepo(prefix, {
withRemote: true,
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
});
cleanupTasks.push(repo.cleanup);
// The helper puts the bare remote inside the checkout; keep it out of the status.
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
return repo.path;
}
async function registerWorkspace(repoPath: string): Promise<string> {
const client = await connectSeedClient();
cleanupTasks.push(async () => {
await client.close().catch(() => undefined);
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
}
return created.workspace.id;
}
async function openCompactActionsMenu(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const menu = page.getByTestId("changes-primary-cta-menu");
await expect(menu).toBeVisible();
return menu;
}
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-pull-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", other]);
});
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
const workspaceId = await registerWorkspace(repoPath);
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Pull",
"Push",
"Pull and push",
"Archive workspace",
]);
});
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-push-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
const workspaceId = await registerWorkspace(repoPath);
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Push",
"Pull",
"Pull and push",
"Archive workspace",
]);
});
@@ -71,6 +71,14 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await closeSheet(page, "linked.ts");
});
await test.step("a subagent's row shows the facts its sheet shows", async () => {
await page.getByTestId("subagents-track-header").click();
await expect(page.getByTestId(`subagents-track-row-facts-${child.id}`)).toHaveText(
"Ten second stream · Low · Load test",
);
await tapSheetBackdrop(page);
});
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
@@ -125,7 +125,7 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
});
const closeExplorer = page
.getByTestId("explorer-header")
.getByTestId("workspace-explorer-sidebar")
.getByRole("button", { name: "Close Explorer sidebar" });
await expect(closeExplorer).not.toBeInViewport();
@@ -809,7 +809,7 @@ async function expectUnmatchedFeedback(page: Page) {
}
function compactReviewSurface(page: Page) {
return page.getByTestId("explorer-content-area").filter({ visible: true });
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
async function openCompactReviewExplorer(
page: Page,
@@ -847,7 +847,10 @@ async function scrollReviewToLastLine(page: Page) {
async function revealCompactChanges(page: Page) {
await page.getByTestId("workspace-explorer-toggle").first().click();
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
await page
.getByTestId("workspace-explorer-sidebar")
.getByTestId("workspace-tab-changes_tree")
.click();
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
}
async function expectReviewEditorBody(page: Page, body: string) {
+16 -14
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "../support/fixtures";
import {
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
@@ -17,7 +18,6 @@ import {
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
import {
closeMobileAgentSidebar,
closeMobileExplorer,
@@ -205,19 +205,21 @@ test.describe("touch screen wide enough for the desktop layout", () => {
title: "Second touch chat",
});
await openWorkspaceWithAgents(page, [first, second]);
await composerLocator(page).fill("Keep this touch draft");
await expectComposerFocused(page);
// An external keyboard changes tabs without a pointer click blurring the
// editor first. The device still has a touch-primary focus policy.
await switchComposerTabWithKeyboard(page, "previous");
await expectComposerDraft(page, "");
await switchComposerTabWithKeyboard(page, "next");
await expectComposerDraft(page, "Keep this touch draft");
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, " after return");
await expectComposerDraft(page, "Keep this touch draft after return");
// Repeat after an explicit edit so retained focus from an earlier visit is
// also dismissed when the same mounted editor becomes inactive again.
for (const draft of ["Keep this touch draft", "Edit this touch draft again"]) {
await composerLocator(page).fill(draft);
await expectComposerFocused(page);
// An external keyboard changes tabs without a pointer click blurring the
// editor first. The device still has a touch-primary focus policy.
await roundTripComposerTabBeforePaint(page, "Second touch chat");
await expectComposerDraft(page, draft);
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, " after return");
await expectComposerDraft(page, `${draft} after return`);
}
} finally {
await workspace.cleanup();
}
@@ -149,7 +149,7 @@ for (const layout of [
await expect(details.getByText(/tokens$/)).toHaveCount(0);
const account = usageCard(details, "login-journey:account");
await expect(account.getByText("Session", { exact: true })).toBeVisible();
await expect(account.getByText("31%", { exact: true })).toBeVisible();
await expect(account.getByText("31% used", { exact: true })).toBeVisible();
await qaScreenshot(page, `context-without-data-${layout.name}`);
await layout.close(page);
});
@@ -161,7 +161,7 @@ for (const layout of [
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
await expect(
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
usageCard(details, "login-journey:account").getByText("31% used", { exact: true }),
).toBeVisible();
});
} finally {
@@ -420,38 +420,36 @@ test("context details support keyboard entry and dismiss when the viewport moves
await expect(details).toHaveCount(0);
});
test("context details preserve composer focus on hover and leave Find available after keyboard dismissal", async ({
page,
agent,
}) => {
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const input = composerLocator(page);
await input.focus();
await hoverContextWindowMeter(page);
await expect(input).toBeFocused();
await leaveContextWindowMeter(page);
await expect(input).toBeFocused();
});
await test.step("pointer entry and dismissal preserve composer focus", async () => {
usage.answerNext([onPersonalLogin(claude!)]);
const input = composerLocator(page);
await input.focus();
await hoverContextWindowMeter(page);
await expect(input).toBeFocused();
await leaveContextWindowMeter(page);
await expect(input).toBeFocused();
test("Find remains available after dismissing context details", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const meter = page.getByTestId("context-window-meter");
const trigger = page.getByRole("button", {
name: (await meter.getAttribute("aria-label"))!,
exact: true,
});
await test.step("keyboard dismissal restores trigger focus and Find remains available", async () => {
usage.answerNext([onPersonalLogin(claude!)]);
const meter = page.getByTestId("context-window-meter");
const trigger = page.getByRole("button", {
name: (await meter.getAttribute("aria-label"))!,
exact: true,
});
await trigger.focus();
await expect(contextWindowDetails(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(contextWindowDetails(page)).toHaveCount(0);
await expect(trigger).toBeFocused();
await page.keyboard.press("ControlOrMeta+f");
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
});
await trigger.focus();
await expect(contextWindowDetails(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(contextWindowDetails(page)).toHaveCount(0);
await expect(trigger).toBeFocused();
await page.keyboard.press("ControlOrMeta+f");
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
});
@@ -0,0 +1,118 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
interface DragMeasurement {
observer: MutationObserver;
stylesheetMutations: number;
}
declare global {
interface Window {
__PASEO_EXPLORER_DRAG_MEASUREMENT__?: DragMeasurement;
}
}
async function waitForResizePaint(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
async function beginResizeMeasurement(page: Page): Promise<void> {
await page.evaluate(() => {
const stylesheet = document.getElementById("unistyles-web");
if (!stylesheet) throw new Error("Shared stylesheet is missing");
globalThis.__PASEO_RESET_RENDER_PROFILE__!();
const measurement: DragMeasurement = {
stylesheetMutations: 0,
observer: new MutationObserver((records) => {
measurement.stylesheetMutations += records.length;
}),
};
measurement.observer.observe(stylesheet, {
childList: true,
subtree: true,
characterData: true,
});
window.__PASEO_EXPLORER_DRAG_MEASUREMENT__ = measurement;
});
}
async function previewExplorerResize(page: Page, distance: number) {
const handle = page
.getByTestId("workspace-explorer-sidebar-resize-handle")
.getByRole("separator");
const explorer = page.getByTestId("workspace-explorer-sidebar");
const bounds = await handle.boundingBox();
if (!bounds) throw new Error("Explorer resize handle is missing");
const before = (await explorer.boundingBox())!.width;
const x = bounds.x + bounds.width / 2;
const y = bounds.y + 100;
await page.mouse.move(x, y);
await page.mouse.down();
await waitForResizePaint(page);
await beginResizeMeasurement(page);
for (let move = 1; move <= 20; move++) {
await page.mouse.move(x + (distance * move) / 20, y);
await waitForResizePaint(page);
}
const during = (await explorer.boundingBox())!.width;
const measurement = await page.evaluate(() => {
const state = window.__PASEO_EXPLORER_DRAG_MEASUREMENT__!;
state.observer.disconnect();
return {
stylesheetMutations: state.stylesheetMutations,
paneRenders: Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {})
.filter(([id]) => id.startsWith("SplitPaneView:"))
.reduce((sum, [, reasons]) => sum + (reasons.render ?? 0), 0),
};
});
await page.mouse.up();
await waitForResizePaint(page);
const after = (await explorer.boundingBox())!.width;
expect(during).toBeCloseTo(before - distance, 0);
expect(after).toBeCloseTo(during, 0);
return { ...measurement, width: after };
}
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
await page.reload();
const explorer = page.getByTestId("workspace-explorer-sidebar");
await expect(explorer).toBeVisible();
await expect.poll(async () => (await explorer.boundingBox())?.width).toBeCloseTo(width, 0);
}
test("Explorer drag previews without rerendering panes or rewriting the shared stylesheet", async ({
page,
withWorkspace,
}) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await page.addInitScript(() => {
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
});
const workspace = await withWorkspace();
await workspace.navigateTo();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page.getByTestId("workspace-explorer-toggle").first().click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
// Verify that direct pane renders are being counted before resetting them.
expect(
await page.evaluate(() =>
Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).some((id) =>
id.startsWith("SplitPaneView:"),
),
),
).toBe(true);
const grown = await previewExplorerResize(page, -100);
const shrunk = await previewExplorerResize(page, 50);
expect(grown.stylesheetMutations).toBe(0);
expect(grown.paneRenders).toBe(0);
expect(shrunk.stylesheetMutations).toBe(0);
expect(shrunk.paneRenders).toBe(0);
await expectExplorerWidthSurvivesReload(page, shrunk.width);
});
+281 -11
View File
@@ -1,3 +1,7 @@
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { writeFile } from "node:fs/promises";
import { openFileFromExplorer } from "../support/helpers/file-explorer";
import { openSettingsSection, clickSettingsBackToWorkspace } from "../support/helpers/settings";
import { expect, test } from "../support/fixtures";
import {
gotoWorkspace,
@@ -136,10 +140,9 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0);
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible();
const entries = await menu.getByRole("menuitem").allTextContents();
expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan(
@@ -191,13 +194,11 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
}
});
async function dragAgentIntoExplorer(
async function dragTabIntoExplorer(
page: Parameters<typeof ensureExplorerSidebar>[0],
agentId: string,
tabId: string,
) {
const source = page
.getByTestId("workspace-pane-main")
.getByTestId(`workspace-tab-agent_${agentId}`);
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
const sourceBox = await source.boundingBox();
const destinationBox = await destination.boundingBox();
@@ -213,7 +214,7 @@ async function dragAgentIntoExplorer(
await page.mouse.up();
}
test("agents can still be dragged into Explorer when absent from its + menu", async ({
test("moves agents into Explorer and keeps the shared Agent launcher available", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" });
@@ -230,14 +231,13 @@ test("agents can still be dragged into Explorer when absent from its + menu", as
});
const main = page.getByTestId("workspace-pane-main");
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
await dragAgentIntoExplorer(page, agent.id);
await dragTabIntoExplorer(page, `agent_${agent.id}`);
await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
await page.keyboard.press("Escape");
await testInfo.attach("agent-in-explorer", {
body: await page.screenshot(),
@@ -247,3 +247,273 @@ test("agents can still be dragged into Explorer when absent from its + menu", as
await workspace.cleanup();
}
});
test("tablet prototype keeps Explorer tabs when resizing down to a phone", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "tablet-explorer-" });
try {
await page.setViewportSize({ width: 951, height: 669 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await ensureExplorerSidebar(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
await openExplorerAgentDraft(page);
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
await page.setViewportSize({ width: 390, height: 844 });
await revealPhoneExplorer(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("phone-shared-explorer.png") });
} finally {
await workspace.cleanup();
}
});
async function openExplorerAgentDraft(page: Parameters<typeof ensureExplorerSidebar>[0]) {
await explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }).click();
await page.getByRole("menuitem", { name: /^Agent/ }).click();
await expect(explorerSidebar(page).getByRole("textbox").first()).toBeVisible();
}
async function revealPhoneExplorer(page: Parameters<typeof ensureExplorerSidebar>[0]) {
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
await expect(explorerSidebar(page)).toBeVisible();
await expect(
explorerSidebar(page).getByRole("button", { name: "Close Explorer sidebar", exact: true }),
).toBeInViewport({ ratio: 1 });
}
async function cycleWorkspaceTheme(page: Parameters<typeof ensureExplorerSidebar>[0]) {
await page.keyboard.press("Control+Alt+t");
}
test("retains the Explorer host through appearance changes and folding", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-lifetime-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
const explorer = await ensureExplorerSidebar(page);
await openFilesPanel(page);
const host = await explorer.elementHandle();
if (!host) throw new Error("Explorer must be mounted before changing appearance");
const previousColor = await explorer.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await cycleWorkspaceTheme(page);
await expect
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(previousColor);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await page.setViewportSize({ width: 1280, height: 900 });
await expect(explorer).toBeVisible();
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
} finally {
await workspace.cleanup();
}
});
async function reorderCompactExplorerTabs(page: Parameters<typeof ensureExplorerSidebar>[0]) {
const explorer = explorerSidebar(page);
const files = explorer.getByTestId("workspace-tab-files");
const changes = explorer.getByTestId("workspace-tab-changes_tree");
const source = await changes.boundingBox();
const target = await files.boundingBox();
if (!source || !target) throw new Error("Explorer tabs must be visible to reorder");
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down();
await page.mouse.move(source.x + source.width / 2 - 12, source.y + source.height / 2);
await page.mouse.move(target.x + 3, target.y + target.height / 2, { steps: 20 });
await page.mouse.up();
await expect
.poll(async () => {
const afterFiles = await files.boundingBox();
const afterChanges = await changes.boundingBox();
return Boolean(afterFiles && afterChanges && afterChanges.x < afterFiles.x);
})
.toBe(true);
}
test("reorders compact Explorer tabs with a pointer without changing selection", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize({ width: 390, height: 844 });
await openFilesPanel(page);
await reorderCompactExplorerTabs(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
).toHaveAttribute("aria-selected", "true");
} finally {
await workspace.cleanup();
}
});
// Exercise appearance at its consumers while the real workspace navigator retains the panels.
test("updates populated code and diff appearance without losing the editor instance or draft", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-populated-appearance-" });
const source = Array.from(
{ length: 120 },
(_, index) => `export const line${index} = "retained ${index}";`,
).join("\n");
try {
await writeFile(`${workspace.repoPath}/appearance.ts`, source);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ codeFontSize: 13, theme: "light" }),
);
localStorage.setItem(
"@paseo:changes-preferences",
JSON.stringify({ inlineDiff: true, layout: "unified" }),
);
});
await gotoWorkspace(page, workspace.workspaceId);
await openFilesPanel(page);
await openFileFromExplorer(page, "appearance.ts");
const editor = page.getByTestId("file-source-editor").filter({ visible: true });
const text = editor.locator(".cm-content");
await expect(text).toBeVisible();
await text.fill(`${source}\n// unsaved local edit`);
const instance = await editor.elementHandle();
const scroller = editor.locator(".cm-scroller");
await scroller.evaluate((node) => {
node.scrollTop = 400;
});
const scrollBefore = await scroller.evaluate((node) => node.scrollTop);
await explorerSidebar(page).getByTestId("workspace-tab-changes_tree").click();
const diff = explorerSidebar(page).getByTestId("git-diff-canvas");
await expect(diff).toBeVisible();
await page.keyboard.press("Control+,");
await openSettingsSection(page, "appearance");
await page.getByLabel("Code font size", { exact: true }).fill("19");
await page.getByLabel("Code font size", { exact: true }).press("Tab");
await clickSettingsBackToWorkspace(page);
await expect(text).toHaveCSS("font-size", "19px");
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("unsaved local edit");
expect(await scroller.evaluate((node) => node.scrollTop)).toBe(scrollBefore);
await expect(diff).toHaveCSS("font-size", "19px");
await cycleWorkspaceTheme(page);
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("unsaved local edit");
await dragTabIntoExplorer(page, "file_appearance.ts");
await expect(explorerSidebar(page).getByTestId("file-source-editor")).toBeVisible();
const explorerEditor = await explorerSidebar(page)
.getByTestId("file-source-editor")
.elementHandle();
await text.fill(`${source}\n// retained Explorer edit`);
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 1280, height: 900 });
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("retained Explorer edit");
} finally {
await workspace.cleanup();
}
});
test("retains populated Explorer chat and terminal attachments through appearance, hide and fold", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "explorer-chat-appearance-",
title: "Retained chat",
initialPrompt: "Show the retention marker",
featureValues: {
mockAssistantResponse: "Explorer populated paragraph.\n\n```ts\nconst retained = 42;\n```",
},
});
const attaches: string[] = [];
page.on("websocket", (socket) =>
socket.on("framesent", (frame) => {
if (typeof frame.payload !== "string") return;
try {
const message = JSON.parse(frame.payload).message;
if (message?.type === "subscribe_terminal_request") attaches.push(message.terminalId);
} catch {
/* Binary terminal traffic is not a JSON request. */
}
}),
);
try {
await openAgentRoute(page, workspace);
await ensureExplorerSidebar(page);
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
const explorer = explorerSidebar(page);
const paragraph = explorer.getByText("Explorer populated paragraph.", { exact: true });
await expect(paragraph).toBeVisible();
const composer = explorer.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Unsubmitted Explorer draft");
const composerNode = await composer.elementHandle();
await page.keyboard.press("Control+,");
await openSettingsSection(page, "appearance");
await page.getByLabel("Content font size", { exact: true }).fill("20");
await page.getByLabel("Content font size", { exact: true }).press("Tab");
await clickSettingsBackToWorkspace(page);
await expect(paragraph).toHaveCSS("font-size", "20px");
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
const previousColor = await paragraph.evaluate((node) => getComputedStyle(node).color);
await cycleWorkspaceTheme(page);
await expect
.poll(() => paragraph.evaluate((node) => getComputedStyle(node).color))
.not.toBe(previousColor);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await page
.getByRole("menuitem", { name: /^Terminal/ })
.first()
.click();
const terminal = explorer.getByTestId("terminal-surface");
await expect(terminal).toBeVisible();
await expect.poll(() => attaches.length).toBe(1);
const terminalNode = await terminal.elementHandle();
await page.getByTestId("workspace-explorer-toggle").first().click();
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 1280, height: 900 });
await cycleWorkspaceTheme(page);
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
expect(attaches).toHaveLength(1);
await explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`).click();
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
await expect(paragraph).toHaveCSS("font-size", "20px");
} finally {
await workspace.cleanup();
}
});
test("compact file sheets cover Explorer and return to its retained file list", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-sheet-layer-" });
try {
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize({ width: 390, height: 844 });
const explorer = await ensureExplorerSidebar(page);
await openFilesPanel(page);
const host = await explorer.elementHandle();
await openFileFromExplorer(page, "sheet-marker.txt");
await expect(page.getByText("Explorer sheet marker", { exact: true })).toBeVisible();
// A real click also proves the sheet draws above the retained Explorer overlay.
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByText("Explorer sheet marker", { exact: true })).toHaveCount(0);
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
expect(await host!.evaluate((node) => node.isConnected)).toBe(true);
} finally {
await workspace.cleanup();
}
});
+122 -83
View File
@@ -105,41 +105,25 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("opens screenshot and non-ASCII Markdown links and explains missing files", async ({
page,
}) => {
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "screenshot-markdown-link-",
title: "Screenshot file links",
initialPrompt: "Show screenshots",
featureValues: {
mockAssistantResponse:
"[Capture](.dev/tablet-review/capture.png) [报告](docs/reports/开户赠金.md) [Missing capture](.dev/tablet-review/missing.png)",
"[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
},
});
try {
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
await openAgentRoute(page, session);
await test.step("Open the linked screenshot", async () => {
await openAssistantMarkdownLink(page, "Capture");
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
});
await test.step("Open the non-ASCII report named by its Markdown link", async () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "报告");
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText("Report opened", { exact: true }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
});
await test.step("Explain why the missing screenshot cannot open", async () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "Missing capture");
@@ -152,6 +136,31 @@ test.describe("CodeMirror workspace file editing", () => {
}
});
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
title: "Non-ASCII file link",
initialPrompt: "Show the report link",
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
});
try {
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
await openAgentRoute(page, session);
await openAssistantMarkdownLink(page, "报告");
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText("Report opened", { exact: true }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
} finally {
await session.cleanup();
}
});
for (const order of ["raw-first", "href-first"] as const) {
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
@@ -681,37 +690,7 @@ test.describe("CodeMirror workspace file editing", () => {
const imagePath = path.join(workspace.repoPath, "pixel.png");
await writeFile(markdownPath, "# First heading\n", "utf8");
await writeFile(imagePath, RED_PIXEL);
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
await writeFile(
path.join(workspace.repoPath, "docs", "guide.md"),
[
"# Guide",
"",
"![sibling](./sibling.png)",
"",
"![hero](../data/cache/hero.png?v=2#preview)",
"",
].join("\n"),
"utf8",
);
await workspace.navigateTo();
await test.step("render sibling and parent-relative images in Markdown preview", async () => {
await openFileExplorer(page);
await expandFolder(page, "docs");
await openFileFromExplorer(page, "guide.md");
await expectFileTabOpen(page, "docs/guide.md");
const guidePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
await expect(guidePane.getByText("Guide", { exact: true })).toBeVisible();
await expectMarkdownImageLoaded(page, "sibling");
await expectMarkdownImageLoaded(page, "hero");
await guidePane.screenshot({
path: test.info().outputPath("markdown-relative-images.png"),
});
});
await openWorkspaceFile(page, "notes.md");
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
@@ -745,30 +724,84 @@ test.describe("CodeMirror workspace file editing", () => {
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
});
test("isolates an HTML preview, blocks fetch egress, and refreshes while retaining source access", async ({
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
await writeFile(
path.join(workspace.repoPath, "docs", "guide.md"),
[
"# Guide",
"",
"![sibling](./sibling.png)",
"",
"![hero](../data/cache/hero.png?v=2#preview)",
"",
].join("\n"),
"utf8",
);
await workspace.navigateTo();
await openFileExplorer(page);
await expandFolder(page, "docs");
await openFileFromExplorer(page, "guide.md");
await expectFileTabOpen(page, "docs/guide.md");
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
await expectMarkdownImageLoaded(page, "sibling");
await expectMarkdownImageLoaded(page, "hero");
await visibleFilePane.screenshot({
path: test.info().outputPath("markdown-relative-images.png"),
});
});
test("previews and refreshes an HTML plan while preserving source access", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-preview-" });
const htmlPath = path.join(workspace.repoPath, "plan.html");
await writeFile(
htmlPath,
"<!doctype html><html><body><h1>Visual plan</h1></body></html>",
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "plan.html");
const preview = htmlPreview(page);
await expect(preview.host).toBeVisible();
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
await writeFile(
htmlPath,
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
"utf8",
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
await selectFileView(page, "Preview");
await expect(preview.host).toBeVisible();
});
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
await writeFile(
path.join(workspace.repoPath, "probe.html"),
`<!doctype html><html><head><title>probe</title></head><body>
<h1>Visual plan</h1>
<h1 id="script-result">Inline script did not run</h1>
<p id="network-result">Network request not attempted</p>
<p id="document-mode">Standards mode not detected</p>
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
<script>
function report(id, label, probe) {
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
}
report("parent", "Parent DOM", function () { return parent.document.body; });
report("storage", "Storage", function () { return localStorage.length; });
report("cookie", "Cookies", function () { return document.cookie; });
</script>
<script>
document.getElementById("script-result").textContent = "Inline script ran";
if (document.compatMode === "CSS1Compat") {
@@ -789,8 +822,6 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "probe.html");
const preview = htmlPreview(page);
await expect(preview.host).toBeVisible();
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
await expect(
preview.document.getByRole("heading", { name: "Inline script ran" }),
).toBeVisible();
@@ -801,26 +832,34 @@ test.describe("CodeMirror workspace file editing", () => {
preview.document.getByText("Network request blocked", { exact: true }),
).toBeVisible();
expect(blockedRequests).toEqual([]);
await test.step("isolate the preview from app origin and storage", async () => {
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
});
await test.step("refresh a changed HTML plan and switch to source and back", async () => {
await writeFile(
path.join(workspace.repoPath, "probe.html"),
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
"utf8",
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
await selectFileView(page, "Preview");
await expect(preview.host).toBeVisible();
});
});
test("isolates HTML plans from the app origin and storage", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-origin-" });
await writeFile(
path.join(workspace.repoPath, "origin.html"),
`<!doctype html><html><body>
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
<script>
function report(id, label, probe) {
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
}
report("parent", "Parent DOM", function () { return parent.document.body; });
report("storage", "Storage", function () { return localStorage.length; });
report("cookie", "Cookies", function () { return document.cookie; });
</script>
</body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "origin.html");
const preview = htmlPreview(page);
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
});
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
@@ -6,14 +6,20 @@ import {
} from "../support/helpers/composer";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
expectNewWorkspaceProjectSelected,
fillNewWorkspaceDraft,
openGlobalNewWorkspaceComposer,
openNewWorkspaceComposer,
selectNewWorkspaceHost,
selectNewWorkspaceProject,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const DRAFT = `Please investigate the workspace startup failure.
@@ -23,6 +29,70 @@ Trace the request from the app through the daemon, preserve the existing behavio
test.describe("New workspace composer draft", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps the draft when the project changes", async ({ page }) => {
const firstProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "new-workspace-draft-project-a-",
});
const secondProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "new-workspace-draft-project-b-",
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectKey,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectKey,
projectDisplayName: secondProject.projectDisplayName,
});
await expectNewWorkspaceDraft(page, DRAFT);
} finally {
await secondProject.cleanup();
await firstProject.cleanup();
}
});
test("keeps the draft when the host changes", async ({ page }) => {
const project: SeededWorkspace = await seedWorkspace({
repoPrefix: "new-workspace-draft-host-",
});
const secondaryServerId = "new-workspace-draft-secondary-host";
try {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "Primary host",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: secondaryServerId,
label: "Secondary host",
endpoint: "127.0.0.1:9",
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceHost(page, "Secondary host");
await expectNewWorkspaceDraft(page, DRAFT);
} finally {
await project.cleanup();
}
});
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
@@ -3,9 +3,6 @@ import { gotoAppShell } from "../support/helpers/app";
import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled,
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
selectNewWorkspaceHost,
expectNewWorkspaceProjectSelected,
expectNewWorkspaceTriggerLabelsAligned,
openGlobalNewWorkspaceComposer,
@@ -93,11 +90,6 @@ test.describe("New workspace entry points", () => {
projectLabel: seeded.projectDisplayName,
hostLabel: "localhost",
});
await test.step("changing hosts preserves the draft", async () => {
await fillNewWorkspaceDraft(page, "Keep this draft while choosing a host");
await selectNewWorkspaceHost(page, "Secondary host");
await expectNewWorkspaceDraft(page, "Keep this draft while choosing a host");
});
} finally {
await seeded.cleanup();
}
@@ -129,9 +121,6 @@ test.describe("New workspace entry points", () => {
timeout: 30_000,
});
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
await test.step("Ctrl+P opens the project picker with search focused", async () => {
await openNewWorkspaceProjectPickerWithShortcut(page);
});
} finally {
await seeded.cleanup();
}
@@ -148,6 +137,20 @@ test.describe("New workspace entry points", () => {
await expectNewWorkspaceControlsEnabled(page);
});
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await openNewWorkspaceProjectPickerWithShortcut(page);
} finally {
await seeded.cleanup();
}
});
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
page,
}) => {
@@ -217,8 +220,6 @@ test.describe("New workspace entry points", () => {
});
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
await fillNewWorkspaceDraft(page, "Keep this draft while choosing a project");
// Manually override the selection to C from inside A's screen. This stale
// manualProjectKey is what the reused 'new' screen must reset when the next
// route-driven navigation targets a different project.
@@ -229,9 +230,6 @@ test.describe("New workspace entry points", () => {
await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click();
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
await test.step("changing projects preserves the draft", async () => {
await expectNewWorkspaceDraft(page, "Keep this draft while choosing a project");
});
// Navigate via B's row icon. B must be preselected — the route project wins
// because the stale manual choice (C) was reset on the route change. If the
@@ -88,23 +88,11 @@ async function openNewWorkspaceFromContextProject(
await expectNewWorkspaceProjectSelected(page, "Context project");
}
const test = base.extend<
{ crossHostProject: CrossHostProjectScenario },
{ secondaryDaemon: IsolatedHostDaemon }
>({
secondaryDaemon: [
async ({ e2eWorker }, provide) => {
void e2eWorker;
const daemon = await startIsolatedHostDaemon("new-workspace-project-preservation-secondary");
try {
await provide(daemon);
} finally {
await daemon.close();
}
},
{ scope: "worker" },
],
crossHostProject: async ({ page: _page, secondaryDaemon }, provide) => {
const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon(
"new-workspace-project-preservation-secondary",
);
const contextRepo = await createTempGitRepo("new-workspace-context-");
const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", {
originUrl: SHARED_REMOTE_URL,
@@ -189,6 +177,7 @@ const test = base.extend<
await primaryDuplicateRepo.cleanup().catch(() => undefined);
await secondaryFallbackRepo.cleanup().catch(() => undefined);
await secondarySharedRepo.cleanup().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
}
},
});
@@ -0,0 +1,100 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
selectBranchInPicker,
} from "../support/helpers/new-workspace";
import { expectNoTruncation } from "../support/helpers/no-truncation";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Regression for "the local / worktree selection in the new workspace is not
// remembered." The isolation choice persists in the create-form preferences
// (FormPreferences.isolation), so it must survive the create→reopen remount:
// creating a worktree workspace navigates away from /new and unmounts it, and
// reopening New Workspace has to still show "New worktree".
test.describe("New workspace isolation memory", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
});
test.afterEach(async () => {
if (client) {
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
// First visit: the screen opens on Local, switch it to New worktree and create.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "local");
await page.getByTestId("workspace-create-isolation-trigger").click();
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(isolationPopup);
await page.getByTestId("workspace-create-isolation-worktree").click();
await expectWorkspaceIsolationSelected(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
// Second visit (fresh mount of /new): the worktree choice must stick.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "worktree");
} finally {
await tempRepo.cleanup();
}
});
});
@@ -0,0 +1,86 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import {
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
attachButton,
expectChatComposerActive,
expectTerminalComposerActive,
fillTerminalPrompt,
seedTerminalProfiles,
selectChatLaunch,
selectLaunchOption,
terminalPromptInput,
type TerminalProfile,
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
const PROFILE: TerminalProfile = {
id: "e2e-composer-profile",
name: "Composer Profile",
command: "/bin/echo",
args: ["{{{prompt}}}"],
};
test.describe("New workspace: the composer is one control in two modes", () => {
let workspace: SeededWorkspace;
let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-composer-" });
profileSeed = await seedTerminalProfiles([PROFILE]);
});
test.afterEach(async () => {
await profileSeed.restore();
await workspace?.cleanup();
});
test("switching launch target does not leak draft text between chat and terminal, and drops the chat-only controls", async ({
page,
}) => {
test.setTimeout(60_000);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await test.step("a chat draft does not leak into the terminal prompt", async () => {
await fillNewWorkspaceDraft(page, "a chat-only draft");
await selectLaunchOption(page, PROFILE.id);
await expectTerminalComposerActive(page);
await expect(terminalPromptInput(page)).toHaveValue("");
});
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
await expect(attachButton(page)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
await expect(
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
name: "Launch",
}),
).toBeVisible();
});
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
await fillTerminalPrompt(page, "a terminal-only draft");
await selectChatLaunch(page);
await expectChatComposerActive(page);
await expectNewWorkspaceDraft(page, "a chat-only draft");
});
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
await selectLaunchOption(page, PROFILE.id);
await expectTerminalComposerActive(page);
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
});
});
});
@@ -0,0 +1,103 @@
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import {
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectChatLaunchSelected,
expectTerminalLaunchSelected,
expectTerminalOutputContains,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
patchTerminalProfiles,
seedTerminalProfiles,
selectChatLaunch,
selectLaunchOption,
submitTerminalLaunch,
type TerminalProfile,
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// Wait for input until project cleanup kills the terminal. A fixed lifetime
// can expire before a busy browser attaches or finishes revisiting the composer.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-memory-profile",
name: "Memory Profile",
command: "/bin/sh",
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
};
function hasLeftNewWorkspaceRoute(url: URL): boolean {
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
}
test.describe("New workspace: launch target memory", () => {
let workspace: SeededWorkspace;
let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-memory-" });
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE]);
});
test.afterEach(async () => {
await profileSeed.restore();
await workspace?.cleanup();
});
test("remembers the launch target across visits, and a removed profile falls back to chat instead of a dead entry", async ({
page,
}) => {
test.setTimeout(90_000);
await gotoAppShell(page);
await waitForSidebarHydration(page);
const openNewWorkspace = () =>
openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
await openNewWorkspace();
await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "remember me");
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
await expectTerminalOutputContains(page, "remembered: remember me");
await openNewWorkspace();
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
});
await test.step("switching to Chat and submitting preselects Chat the next time", async () => {
await selectChatLaunch(page);
await expectChatLaunchSelected(page);
await submitNewWorkspacePrompt(page, "hello from chat");
// The chat handoff navigates asynchronously (draft -> created agent). Let
// it fully settle before moving on, so it can't fire a late redirect
// that races the next step's navigation.
await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 });
await openNewWorkspace();
await expectChatLaunchSelected(page);
});
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "second run");
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
await patchTerminalProfiles([]);
await openNewWorkspace();
await expectChatLaunchSelected(page);
});
});
});
@@ -5,7 +5,6 @@ import {
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import {
composerLocator,
@@ -16,12 +15,6 @@ import {
} from "../support/helpers/composer";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
attachButton,
expectChatComposerActive,
expectTerminalComposerActive,
expectChatLaunchSelected,
patchTerminalProfiles,
selectChatLaunch,
expectTerminalLaunchSelected,
expectTerminalOutputContains,
expectTerminalPreviewCommand,
@@ -74,50 +67,21 @@ test.describe("New workspace: launching a terminal", () => {
await workspace?.cleanup();
});
test("switch launch modes, run prompt and bare profiles, and remember successful targets", async ({
test("a profile that takes a prompt launches with it as its trailing argument; a profile without the sentinel launches bare from Launch", async ({
page,
}) => {
test.setTimeout(90_000);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await test.step("open New workspace to choose a launch target", async () => {
await test.step("pick a profile that takes a prompt, type one, and submit", async () => {
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
});
await test.step("a chat draft does not leak into the terminal prompt", async () => {
await fillNewWorkspaceDraft(page, "a chat-only draft");
await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalComposerActive(page);
await expect(terminalPromptInput(page)).toHaveValue("");
});
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
await expect(attachButton(page)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
await expect(
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
name: "Launch",
}),
).toBeVisible();
});
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
await fillTerminalPrompt(page, "a terminal-only draft");
await selectChatLaunch(page);
await expectChatComposerActive(page);
await expectNewWorkspaceDraft(page, "a chat-only draft");
});
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalComposerActive(page);
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
});
await test.step("submit the prompt profile", async () => {
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "fix the flaky test");
await submitTerminalLaunch(page);
});
@@ -132,7 +96,6 @@ test.describe("New workspace: launching a terminal", () => {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await selectLaunchOption(page, BARE_PROFILE.id);
await expect(terminalPromptInput(page)).toHaveCount(0);
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat");
@@ -144,31 +107,6 @@ test.describe("New workspace: launching a terminal", () => {
await expectWorkspaceOpensWithTerminalTab(page);
await expectTerminalOutputContains(page, "bare-launch-static-line");
});
await test.step("successful launches remember their target across visits", async () => {
await openNewWorkspaceComposer(page, workspace);
await expectTerminalLaunchSelected(page, BARE_PROFILE.name);
await selectChatLaunch(page);
await expectChatLaunchSelected(page);
await submitNewWorkspacePrompt(page, "hello from chat");
await page.waitForURL(hasLeftNewWorkspaceRoute, {
timeout: 30_000,
});
await openNewWorkspaceComposer(page, workspace);
await expectChatLaunchSelected(page);
});
await test.step("a removed remembered terminal profile falls back to Chat", async () => {
await selectLaunchOption(page, PROMPT_PROFILE.id);
await fillTerminalPrompt(page, "second run");
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
try {
await patchTerminalProfiles([]);
await openNewWorkspaceComposer(page, workspace);
await expectChatLaunchSelected(page);
} finally {
await patchTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
}
});
});
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
@@ -219,10 +157,6 @@ test.describe("New workspace: launching a terminal", () => {
});
});
function hasLeftNewWorkspaceRoute(url: URL): boolean {
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
}
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
}
@@ -313,9 +247,7 @@ test.describe("New workspace: fullscreen composer across the compact breakpoint"
await expectNewWorkspaceDraft(page, `${draft} wide`);
});
// Terminal mode and its prompt are screen state, and the shell remounts the screen
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
// layout only.
// Exercise the terminal prompt's fullscreen and compact launch sheet on the phone.
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
await resizeComposerViewport(page, "portrait");
// The compact launch control opens a sheet, not the desktop menu.
@@ -172,9 +172,7 @@ test.describe("New workspace preselects the open workspace's project", () => {
await projectB?.cleanup();
});
test("workspace button and shortcut follow the active project across visits", async ({
page,
}) => {
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -185,15 +183,6 @@ test.describe("New workspace preselects the open workspace's project", () => {
});
await pressNewWorkspaceShortcut(page);
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: projectB.workspaceId,
});
await test.step("global button follows project B", async () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
});
await switchWorkspaceViaSidebar({
page,
@@ -202,15 +191,27 @@ test.describe("New workspace preselects the open workspace's project", () => {
});
await pressNewWorkspaceShortcut(page);
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
});
test("New workspace button preselects the project you are looking at", async ({ page }) => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: projectB.workspaceId,
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: projectA.workspaceId,
});
await test.step("global button follows project A", async () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
});
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
+54 -26
View File
@@ -1,4 +1,3 @@
import { expectNoTruncation } from "../support/helpers/no-truncation";
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
@@ -34,7 +33,6 @@ import {
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
selectWorkspaceIsolation,
expectWorkspaceIsolationSelected,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import {
@@ -725,26 +723,9 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "local");
await page.getByTestId("workspace-create-isolation-trigger").click();
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(isolationPopup);
await page.getByTestId("workspace-create-isolation-worktree").click();
await expectWorkspaceIsolationSelected(page, "worktree");
await test.step("cancel the branch picker without changing the selection", async () => {
await openBranchPicker(page);
await expectPickerOpen(page);
await closeBranchPicker(page);
await expectPickerClosed(page);
});
await test.step("select the filtered starting branch with the keyboard", async () => {
await openBranchPicker(page);
await expectPickerOpen(page);
await selectPickerOptionByKeyboard(page, "dev");
await expectPickerSelected(page, "dev");
await expectPickerClosed(page);
});
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page
.getByTestId("message-input-root")
@@ -768,10 +749,6 @@ test.describe("New workspace flow", () => {
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory));
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
await test.step("the worktree choice survives creating and reopening New workspace", async () => {
await openNewWorkspaceComposer(page, openedProject);
await expectWorkspaceIsolationSelected(page, "worktree");
});
} finally {
await tempRepo.cleanup();
}
@@ -913,6 +890,57 @@ test.describe("New workspace flow", () => {
});
});
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
page,
}) => {
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
await selectPickerOptionByKeyboard(page, "dev");
await expectPickerSelected(page, "dev");
await expectPickerClosed(page);
} finally {
await tempRepo.cleanup();
}
});
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
const tempRepo = await createTempGitRepo("picker-escape-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
await closeBranchPicker(page);
await expectPickerClosed(page);
} finally {
await tempRepo.cleanup();
}
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
@@ -0,0 +1,32 @@
import { expect, test } from "../support/fixtures";
import { expectComposerModel } from "../support/helpers/agent-profiles";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
import { submitMessage } from "../support/helpers/composer";
async function restoreHostConnection(gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>) {
gate.holdNextServerMessage("status");
gate.restore();
await gate.waitForHeldServerMessage("status");
gate.releaseHeldServerMessage("status");
}
test("an offline workspace draft reports the disconnected host instead of loading models", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace();
await workspace.navigateTo();
await gate.drop();
await gate.waitForBlockedConnection();
await createAgentTabFromMenu(page);
await submitMessage(page, "hello");
await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveText(
"Host is not connected",
);
await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0);
await restoreHostConnection(gate);
await expectComposerModel(page, "Ten second stream");
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello");
});
+31 -13
View File
@@ -226,19 +226,10 @@ test.describe("narrow touch browser", () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await test.step("reveal the first match below the floating Find widget", async () => {
await expect(status(page)).toHaveText("1 of 2");
await expectActiveMatchUncovered(page);
});
await test.step("tap the next uncovered match while controls stay inside the pane", async () => {
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectActiveMatchUncovered(page);
await expectFindInsidePane(page);
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
await expect(query(page)).toHaveValue("needle");
});
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectFindInsidePane(page);
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
@@ -341,6 +332,33 @@ for (const startingField of ["Find in pane", "Replace with"] as const) {
}
test.describe("the active match stays visible under the floating widget", () => {
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("reveals a short file's match that the widget would cover", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await expectActiveMatchUncovered(page);
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
await expect(query(page)).toHaveValue("needle");
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
page,
withWorkspace,
@@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => {
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
});
await captureToolCall(page, testInfo, {
label: "Send agent prompt",
label: "Send message",
artifact: "send-prompt-presentation",
expectedText: ["Prompt", GREETING, "Agent", "Result"],
});
@@ -188,3 +188,121 @@ async function measureInlineCode(message: Locator) {
return { codes, right: paragraph.getBoundingClientRect().right };
});
}
test("inline-code paths preserve stable recognition and file navigation", async ({
page,
context,
}, testInfo) => {
const { writeFile, mkdir } = await import("node:fs/promises");
const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"];
const source =
paths.map((path) => `Open \`${path}\`.`).join("\n\n") +
"\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain.";
const session = await seedMockAgentWorkspace({
repoPrefix: "inline-path-regression-",
title: "Inline path regression",
initialPrompt: "Show file paths",
featureValues: { mockAssistantResponse: source },
});
try {
await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true });
for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`);
await writeFile(
`${session.cwd}/src/index.ts`,
Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"),
);
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible();
await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0);
await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0);
for (const path of paths) {
await openInlineRegressionFile(page, path);
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText(
`Opened ${path}`,
);
}
await openInlineRegressionFile(page, "src/index.ts#L10-L20");
await expect(page.getByLabel("Line 20, column 26")).toBeVisible();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-content")
.focus();
await page.keyboard.press("ControlOrMeta+c");
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(
Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"),
);
await expect(
page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-selectionBackground"),
).not.toHaveCount(0);
await testInfo.attach("inline-path-line-selection", {
body: await page.screenshot(),
contentType: "image/png",
});
} finally {
await session.cleanup();
}
});
async function openInlineRegressionFile(page: Page, path: string) {
await page.getByRole("button", { name: "Inline path regression", exact: true }).click();
await page
.getByTestId("assistant-message")
.getByRole("link", { name: path, exact: true })
.click();
}
// Web links are styled inline by the library, so hover state has to restyle the anchor.
test("markdown links underline on hover", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "markdown-link-hover-",
title: "Markdown link hover",
featureValues: {
mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).",
},
initialPrompt: "Show links",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
for (const name of ["the docs", "README"]) {
const link = message.getByRole("link", { name, exact: true });
await expect(link).toHaveCSS("text-decoration-line", "none");
await link.hover();
await expect(link).toHaveCSS("text-decoration-line", "underline");
await page.mouse.move(0, 0);
await expect(link).toHaveCSS("text-decoration-line", "none");
}
} finally {
await session.cleanup();
}
});
const PIXEL_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
test("Markdown images show a zoom-in cursor", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "markdown-image-cursor-",
title: "Markdown image cursor",
featureValues: { mockAssistantResponse: `Before.\n\n![pixel](${PIXEL_PNG})\n\nAfter.` },
initialPrompt: "Show image",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const image = page.getByTestId("assistant-message").getByRole("button", { name: "pixel" });
await expect(image).toHaveCSS("cursor", "zoom-in");
} finally {
await session.cleanup();
}
});
@@ -5,10 +5,6 @@ import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "../support/helpers/sidebar";
const PLUGIN_ID = "plugin-host-ui-e2e";
@@ -91,12 +87,6 @@ async function useCompactLayout(page: Page): Promise<void> {
await page.setViewportSize({ width: 390, height: 844 });
}
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Host UI", exact: true }).click();
}
async function openPluginModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
@@ -158,7 +148,6 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
await useCompactLayout(page);
await reopenHostUiPluginFromCompactSidebar(page);
await openPluginModal(page);
await expectCompactPluginSheet(page);
await savePluginIssue(page);
@@ -0,0 +1,138 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "plugin-icons-e2e";
const SVG_PATH = "M4 12l5 5L20 6";
const pluginClientSource = `import React from "react";
import { Text, View } from "react-native";
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
function BadgeIcon({ size, color }) {
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function Panel() {
return <View><Text>Plugin icon panel</Text></View>;
}
const panels = [
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
];
export default function contribute(client) {
for (const panel of panels) {
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
client.addCommandCenterItem({
id: panel.id,
title: "Open " + panel.title,
icon: panel.icon,
context: "workspace",
onSelect({ openPanel }) { openPanel(panel.id); },
});
}
return () => {};
}`;
function pluginTab(page: Page, panelId: string): Locator {
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin icons", () => {
test.describe.configure({ timeout: 180_000 });
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await test.step("Command Center rows draw each icon form", async () => {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("icon panel");
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
await expect(
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
).toBeVisible();
}
await expect(
panel
.getByRole("button", { name: "Open SVG icon panel", exact: true })
.locator(`path[d="${SVG_PATH}"]`),
).toBeVisible();
await expect(
panel
.getByRole("button", { name: "Open Component icon panel", exact: true })
.getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "command-center-icons");
await page.keyboard.press("Escape");
});
await test.step("workspace panel tabs draw each icon form", async () => {
await runCommand(page, "Open Lucide icon panel");
await runCommand(page, "Open SVG icon panel");
await runCommand(page, "Open Component icon panel");
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
await expect(
pluginTab(page, "component").getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "workspace-tab-icons");
});
} finally {
try {
await client.removePlugin(PLUGIN_ID);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await rm(directory, { recursive: true, force: true });
}
}
});
});
@@ -749,6 +749,7 @@ function observePluginReload(page: Page) {
return {
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
requestedAt: () => requests[0]!,
requestCount: () => requests.length,
};
}
@@ -813,6 +814,12 @@ async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string
});
}
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
const remaining = 66_000 - (Date.now() - requestedAt);
if (remaining > 0) await page.waitForTimeout(remaining);
}
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
page,
pluginEnvironment,
@@ -826,9 +833,6 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
"Plugin v1",
);
// Advance the real browser client's RPC timers while the daemon's build gate
// remains held on its own clock.
await page.clock.install();
// Observe before the app loads: its daemon socket comes up with the page.
const reload = observePluginReload(page);
await gotoAppShell(page);
@@ -847,7 +851,7 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
await expectPluginRowPending(page, "e2e-plugin");
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
await page.clock.fastForward(66_000);
await waitPastFormerReloadDeadline(page, reload.requestedAt());
// Past the former deadline the reload is still pending, the row shows its previous state, and
// no timeout error reached the surface.
@@ -2,10 +2,6 @@ import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { expect, test as base, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
openMobileAgentSidebar,
expectMobileAgentSidebarVisible,
} from "../support/helpers/sidebar";
async function openExample(page: Page, example: string) {
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
@@ -122,9 +118,7 @@ async function openWideExamples(page: Page) {
async function openCompactExamples(page: Page) {
await page.setViewportSize({ width: 390, height: 844 });
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
}
async function expectDeniedCopyFeedback(page: Page) {
@@ -0,0 +1,51 @@
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
import { expect, test, type Page } from "../support/fixtures";
import type { TestInfo } from "@playwright/test";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import {
findAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
async function openProviderCatalog(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
}
async function expectProviderArtwork(
page: Page,
testInfo: TestInfo,
id: "devin" | "hermes" | "kiro",
name: string,
) {
await findAcpCatalogProvider(page, name);
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
await expect(icon).toBeVisible();
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
await expect.soft(icon).toHaveAttribute("width", "24");
const expectedPaths = await page.evaluate((xml) => {
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
}, ACP_PROVIDER_ICON_SVGS[id]);
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
for (const [index, path] of expectedPaths.entries()) {
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
}
await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) });
}
test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({
page,
}, testInfo) => {
await openProviderCatalog(page);
await expectProviderArtwork(page, testInfo, "devin", "Devin CLI");
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
});
@@ -15,6 +15,7 @@ async function closeMenuSheet(page: Page): Promise<void> {
test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" });
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-filter-" });
try {
await gotoAppShell(page);
@@ -26,6 +27,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
/^Grouping/,
/^Title/,
"Show",
"Project",
"Show background",
]);
await closeMenuSheet(page);
@@ -53,6 +55,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
});
await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible();
} finally {
await secondProject.cleanup();
await seeded.cleanup();
}
});
@@ -1,4 +1,5 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
@@ -31,6 +32,28 @@ import {
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
async function showDeveloperToast(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById("error-toast")?.remove();
const toast = document.createElement("div");
toast.id = "error-toast";
toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
// Expo renders its unnamed dismiss button in an open shadow root.
const button = document.createElement("button");
button.style.cssText = "width:24px;height:24px";
button.addEventListener("click", () => toast.remove());
toast.attachShadow({ mode: "open" }).appendChild(button);
document.body.appendChild(toast);
});
}
test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
await gotoAppShell(page);
await showDeveloperToast(page);
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
await expect(page.locator("#error-toast")).toBeHidden();
});
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -9,6 +9,8 @@ import {
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openGroupedProjectWorkspace,
expectWorkspaceHost,
openProjectsForSettingsHost,
openProjectDirectory,
openProjectDirectoryWithHosts,
@@ -418,4 +420,29 @@ test.describe("Sidebar project grouping", () => {
count: 2,
});
});
test("creates grouped project workspaces on the active workspace host", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, SECONDARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: SECONDARY_HOST_LABEL,
count: 2,
});
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, PRIMARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: PRIMARY_HOST_LABEL,
count: 2,
});
});
});
@@ -316,9 +316,7 @@ test.describe("Sidebar workspace list", () => {
}
});
test("opens a GitHub-backed workspace with folder metadata and pointer and keyboard hover cards", async ({
page,
}) => {
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-remote-",
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
@@ -337,46 +335,6 @@ test.describe("Sidebar workspace list", () => {
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).not.toContainText("test-owner/test-repo");
expect(workspace.workspaceName).toBe("main");
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
await expect(row).toContainText("main");
await test.step("inspect host metadata before opening the workspace", async () => {
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
});
await test.step("open the workspace and inspect its selected folder header", async () => {
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: projectName,
});
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
});
await test.step("dismiss a clicked row's hover card when the pointer leaves", async () => {
await row.click();
await expect(row).toBeFocused();
await expectWorkspaceHoverCardOpen(page);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
});
await test.step("retain keyboard preview until Escape or focus leaves the row", async () => {
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
await expectWorkspaceHoverCardOpen(page);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardStaysOpen(page);
await page.keyboard.press("Escape");
await expectWorkspaceHoverCardClosed(page);
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
await expectWorkspaceHoverCardOpen(page);
await page.keyboard.press("Tab");
await expectWorkspaceHoverCardClosed(page);
});
} finally {
await workspace.cleanup();
}
@@ -396,6 +354,107 @@ test.describe("Sidebar workspace list", () => {
}
});
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
page,
}) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-header-",
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
});
try {
const projectName = path.basename(workspace.repoPath);
await gotoAppShell(page);
await waitForSidebarProject(page, projectName);
await waitForSidebarWorkspace(page, workspace.workspaceId);
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: projectName,
});
} finally {
await workspace.cleanup();
}
});
test("git project shows branch name in workspace row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
expect(workspace.workspaceName).toBe("main");
await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText(
"main",
);
} finally {
await workspace.cleanup();
}
});
test("workspace hover card shows host as metadata", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
} finally {
await workspace.cleanup();
}
});
test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
await row.click();
await expect(row).toBeFocused();
await expectWorkspaceHoverCardOpen(page);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
});
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
await expectWorkspaceHoverCardOpen(page);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardStaysOpen(page);
await page.keyboard.press("Escape");
await expectWorkspaceHoverCardClosed(page);
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
await expectWorkspaceHoverCardOpen(page);
await page.keyboard.press("Tab");
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
});
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "sidebar-mark-unread-",
@@ -436,21 +495,26 @@ test.describe("Sidebar workspace list", () => {
test.describe("Mobile sidebar panelState transition", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("opens mobile navigation beside the close button and closes it again", async ({ page }) => {
test("showMobileAgent open and close transition", async ({ page }) => {
await gotoAppShell(page);
await expectMobileAgentSidebarHidden(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await test.step("place the close button beside the first navigation row", async () => {
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
});
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
});
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
});
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
page,
}) => {
@@ -523,14 +587,15 @@ test.describe("Half-screen desktop layout", () => {
}
});
test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => {
await gotoAppShell(page);
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
});
test("keeps the left toggle center-owned without left window controls", async ({ page }) => {
await gotoAppShell(page);
await test.step("start with pinned navigation at half of a 14-inch Mac display", async () => {
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
});
const openToggle = page.getByTestId("menu-button");
const openIcon = openToggle.locator("svg").first();
await expect(openIcon).toBeVisible();
@@ -559,7 +624,9 @@ test.describe("Half-screen desktop layout", () => {
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
});
test("keeps app navigation beside the Explorer pane", async ({ page }) => {
test("yields app navigation to Explorer and restores it when Explorer closes", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
try {
@@ -571,7 +638,7 @@ test.describe("Half-screen desktop layout", () => {
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expect(
page
@@ -266,14 +266,22 @@ test("keeps overview tool calls inline on desktop", async ({ page }) => {
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
try {
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible();
await group.click();
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
const header = group.getByRole("button").first();
await header.click();
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
gate.release(1);
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
} finally {
await agent.cleanup();
}
+163 -11
View File
@@ -4,9 +4,10 @@ import path from "node:path";
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { openSettingsSection } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import {
@@ -16,6 +17,7 @@ import {
} from "../support/helpers/usage-reports";
import {
installLoginUsage,
expectLoginUsageFreshness,
installCodexWindowUsage,
expectCodexReportedWindows,
refreshLoginUsage,
@@ -130,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
await refreshAllUsage(page);
await usage.waitForListRequests(requestCount + 1);
const group = page.getByTestId(`usage-host-${getServerId()}`);
await expect(group.getByText("41%", { exact: true })).toBeVisible();
await expect(group.getByText("41% used", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
}
@@ -177,7 +179,7 @@ test.describe("usage modal", () => {
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(hostFilter(page)).toHaveCount(0);
await qaScreenshot(page, "usage-modal-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("31% used")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -239,15 +241,15 @@ test.describe("usage modal", () => {
await gotoAppShell(page);
await openUsageFromItem(page);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
const alphaRefresh = group.getByTestId("usage-refresh").first();
await alphaRefresh.click();
await expect
.poll(() => forcedRefreshes(usage))
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
await expect(group.getByText("58%")).toBeVisible();
await expect(group.getByText("12%")).toBeVisible();
await expect(group.getByText("58% used")).toBeVisible();
await expect(group.getByText("12% used")).toBeVisible();
await group.getByTestId("usage-refresh").nth(1).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
@@ -331,7 +333,7 @@ test.describe("usage modal", () => {
await hostFilter(page).click();
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-modal-picked-host");
@@ -346,7 +348,7 @@ test.describe("usage modal", () => {
await expectPinnedUsage(page, ["12% Weekly"]);
await openUsageFromItem(page);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
} finally {
await secondary.close().catch(() => undefined);
@@ -512,7 +514,7 @@ for (const theme of ["light", "dark"] as const) {
await expect(screen.getByText("69% left")).toBeVisible();
await showUsageAs(page, "used");
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, 31% · resets /,
/^Pin Claude Session, 31% used · resets /,
);
await openUsageOptions(page);
await closeUsageOptions(page);
@@ -575,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
},
]);
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
await expectLoginUsageOnly(page, "42% used");
} finally {
await fixture.cleanup();
}
@@ -588,6 +590,156 @@ async function expectLoginErrors(page: Page, lines: string[]) {
async function expectLoginUsageOnly(page: Page, percentage: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
}
for (const compact of [false, true]) {
test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({
page,
}) => {
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
try {
await page.clock.install();
await page.setViewportSize(
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
await gotoAppShell(page);
if (compact) await openCompactSidebar(page);
await openUsageFromIcon(page);
await expectLoginErrors(page, ["Claude: Initial failure"]);
await test.step("developer errors do not interrupt freshness inspection", async () => {
await page.evaluate(() => console.error("E2E: developer toast while Usage is open"));
await expectLoginUsageFreshness(page, "No successful update", compact);
});
await fixture.setReport({
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }],
});
// Move only the browser clock; real daemon fetches continue using the host clock.
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42% used");
await advanceUsageClock(page, 60_000);
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
await fixture.setReport({ status: "error", error: "Rate limited" });
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await fixture.setReport({
status: "unavailable",
problem: { kind: "no_quota", detail: "No active quota" },
});
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: No active quota");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await fixture.setReport({
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }],
});
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43% used");
await advanceUsageClock(page, 60_000);
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
} finally {
await fixture.cleanup();
}
});
}
async function expectRetainedLoginUsage(page: Page, message: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
}
async function switchToJapanese(page: Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await openSettings(page);
await openSettingsSection(page, "general");
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
await page.getByRole("button", { name: "戻る", exact: true }).click();
}
async function openJapaneseUsage(page: Page) {
await page.getByRole("button", { name: "使用状況", exact: true }).click();
await expect(page.getByTestId("usage-modal").first()).toBeVisible();
}
test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
test.setTimeout(120_000);
const login = await installLoginUsage({
status: "available",
windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
});
try {
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
await switchToJapanese(page);
await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
await openJapaneseUsage(page);
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
await page.getByRole("button", { name: "設定", exact: true }).last().click();
await expect(page.getByText("割合", { exact: true })).toBeVisible();
await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
await expect(page.getByText("残り", { exact: true })).toBeVisible();
} finally {
await login.cleanup();
}
});
test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
test.setTimeout(120_000);
const now = new Date();
const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
const login = await installLoginUsage({
status: "available",
windows: [
{ id: "session", label: "Session", usedPct: 42, resetsAt: reset },
{ id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
],
balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
});
try {
await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets (28|29)m$/,
);
} finally {
await login.cleanup();
}
});
async function openCompactUsageWithClock(page: Page, now: Date) {
await page.setViewportSize({ width: 390, height: 844 });
await page.clock.install({ time: now });
await gotoAppShell(page);
await openCompactSidebar(page);
await openUsageFromIcon(page);
}
async function advanceUsageClock(page: Page, milliseconds: number) {
await page.clock.fastForward(milliseconds);
}
@@ -93,7 +93,7 @@ test.describe("usage settings", () => {
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("70% used")).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
@@ -131,11 +131,11 @@ test.describe("usage settings", () => {
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("usage-card");
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 });
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
await expect(card.getByText("64%")).toBeVisible();
await expect(card.getByText("64% used")).toBeVisible();
});
test("asks to update a host without usage support and never calls it", async ({ page }) => {
@@ -1,7 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { test } from "../support/fixtures";
import { createControlledStream } from "../support/helpers/controlled-stream";
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
@@ -28,46 +27,31 @@ interface ViewedTimelineScenario {
cleanup(): Promise<void>;
}
type ViewedAgentConfig = Pick<
Parameters<SeedDaemonClient["createAgent"]>[0],
"provider" | "modeId" | "model"
>;
async function seedViewedTimelineScenario(
options: { firstAgent?: ViewedAgentConfig } = {},
options: { firstAgentModel?: string } = {},
): Promise<ViewedTimelineScenario> {
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
const createAgent = (
title: string,
config: ViewedAgentConfig = {
provider: "mock",
modeId: "load-test",
model: "e2e-fast-stream",
},
) =>
const createAgent = (title: string, model = "ten-second-stream") =>
workspace.client.createAgent({
...config,
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model,
});
try {
const [firstAgent, secondAgent] = await Promise.all([
createAgent("First viewed chat", options.firstAgent),
createAgent("Second viewed chat"),
]);
return {
client: workspace.client,
workspaceId: workspace.workspaceId,
repoPath: workspace.repoPath,
firstAgentId: firstAgent.id,
secondAgentId: secondAgent.id,
cleanup: workspace.cleanup,
};
} catch (error) {
await workspace.cleanup();
throw error;
}
const [firstAgent, secondAgent] = await Promise.all([
createAgent("First viewed chat", options.firstAgentModel),
createAgent("Second viewed chat"),
]);
return {
client: workspace.client,
workspaceId: workspace.workspaceId,
repoPath: workspace.repoPath,
firstAgentId: firstAgent.id,
secondAgentId: secondAgent.id,
cleanup: workspace.cleanup,
};
}
interface RestoredLayoutChat {
@@ -322,39 +306,19 @@ test.describe("Viewed agent timelines", () => {
}) => {
test.setTimeout(150_000);
const subscriptions = observeTimelineSubscriptions(page);
const stream = await createControlledStream();
const scenario = await seedViewedTimelineScenario({
firstAgent: { provider: stream.provider },
}).catch(async (error) => {
await stream.cleanup();
throw error;
});
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
try {
await openAgent(page, scenario, scenario.firstAgentId);
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
await stream.append(["The first chat is still producing output."]);
await expect(
page.getByText("The first chat is still producing output.", { exact: true }),
).toBeVisible();
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
const agents = await scenario.client.fetchAgents();
expect(
agents.entries.find((entry) => entry.agent.id === scenario.firstAgentId)?.agent.status,
).toBe("running");
await stream.finish([" Completed while hidden."]);
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
expect(finish.status).toBe("idle");
await selectAgent(page, "First viewed chat");
await expectAgentConsistentlyIdle(page, "First viewed chat");
await expect(page.getByText("Completed while hidden.", { exact: false })).toBeVisible();
} finally {
try {
await stream.cleanup();
} finally {
await scenario.cleanup();
}
await scenario.cleanup();
}
});
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -18,6 +18,7 @@ import {
} from "../support/helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
@@ -28,6 +29,7 @@ import {
closeWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
observeSetupPanelTraffic,
} from "../support/helpers/workspace-setup";
interface WorkspaceScriptStarter {
@@ -91,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -116,6 +119,45 @@ test.describe("Workspace setup streaming", () => {
}
});
test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({
page,
}) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-membership-", {
paseoConfig: { worktree: { setup: ["echo setup complete"] } },
});
const traffic = observeSetupPanelTraffic(page);
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed",
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "setup-membership",
});
await completed;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupTabNotSeeded(page, workspace.id);
await traffic.expectNoDemand();
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 1);
await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete");
await clickNewChat(page);
await expectComposerVisible(page);
await traffic.expectReleased(0);
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 2);
await closeSetupTab(page, workspace.id);
await traffic.expectReleased(1);
} finally {
await client.close();
await repo.cleanup();
}
});
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
@@ -130,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
(payload) =>
payload.status === "running" &&
setupCommandOutput(payload.detail).includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -152,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
@@ -175,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
(payload) =>
payload.status === "failed" &&
setupCommandOutput(payload.detail).includes("setup failed"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
@@ -184,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
@@ -291,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -337,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const result = await client.createPaseoWorktree({
@@ -4,8 +4,8 @@ env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-explorer-toggle\""
wait "id=\"explorer-tab-files\"" 10000
press "id=\"explorer-tab-files\""
wait "id=\"workspace-tab-files\"" 10000
press "id=\"workspace-tab-files\""
wait "id=\"file-explorer-row-0\"" 10000
longpress "id=\"file-explorer-row-0\"" 600
wait "id=\"file-explorer-row-0-context-menu-content\"" 10000
@@ -0,0 +1,19 @@
# Prerequisite: a native terminal showing the output of echo '한글 문장'
# on its first row, with no active selection. Use a dedicated QA workspace.
# Coordinates target the first half of 문 on a 1080x1920 phone at default
# code size. Override WORD_X/WORD_Y for other device geometry.
context platform=android timeout=30000 retries=0
env APP_ID=sh.paseo.debug
env WORD_X=105
env WORD_Y=251
open "${APP_ID}"
wait "label=\"Terminal input\"" 10000
wait "label=\"한글 문장\"" 10000
longpress "${WORD_X}" "${WORD_Y}" 650
wait "id=\"terminal-copy\"" 5000
press "id=\"terminal-copy\""
# Copy clears the selection; only Android's clipboard preview has this exact
# text, since the unselected terminal row contains the full sentence.
wait "text=\"문장\"" 3000
close
@@ -0,0 +1,145 @@
// Run against an Android dev client serving this checkout, with a real software keyboard.
// node packages/app/e2e/mobile/appearance-keyboard/android.mjs <session> <metro-url> <server-id> [app-id]
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl, serverId, appId = "sh.paseo.debug"] = process.argv;
assert(
session && metroUrl && serverId,
"Pass the agent-device session, Metro URL and connected host ID",
);
const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
return ad("cdp", "runtime", "eval", "--expr", expression, "--json");
}
// Read the mounted app's public React Native APIs through Metro. Accessibility
// snapshots with Gboard open can block while waiting for an idle hierarchy.
const reactNative = `Array.from(__r.getModules().values()).find(
m => m.verboseName?.endsWith("/react-native/index.js")
).publicModule.exports`;
function measureFocusedInput() {
evaluate(`(() => {
const rn = ${reactNative};
globalThis.__appearanceKeyboardMeasurement = null;
rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => {
globalThis.__appearanceKeyboardMeasurement = JSON.stringify({
x, y, width, height,
statusBarHeight: rn.StatusBar.currentHeight,
scale: rn.PixelRatio.get(),
keyboard: rn.Keyboard.metrics(),
});
});
})()`);
const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement"));
return value ? JSON.parse(value) : null;
}
async function waitForFocusedInput() {
let measurement;
let previous;
let stable = 0;
const deadline = Date.now() + 30_000;
do {
measurement = measureFocusedInput();
const current = measurement?.keyboard
? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",")
: null;
stable = measurement?.keyboard && current === previous ? stable + 1 : 0;
previous = current;
if (stable >= 3) break;
await delay(150);
} while (Date.now() < deadline);
assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`);
return measurement;
}
ad("open", appId, "--platform", "android", "--no-test-ime");
const targets = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.filter((line) => line.includes(appId));
assert.equal(targets.length, 1, "Connect only the test app to this Metro server");
ad("cdp", "target", "select", targets[0].split(/\s+/)[0]);
try {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Appearance", "5000");
ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Code size", "5000");
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
const numericFields = snapshot.data.nodes.filter(
(node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""),
);
assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling");
ad("screenshot", resolve(artifacts, "before-keyboard.png"));
// Appearance orders Interface size, Content size, then Code size.
ad("press", `@${numericFields[2].ref}`);
const measurement = await waitForFocusedInput();
// Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates.
const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight;
const keyboardTop = measurement.keyboard.screenY;
console.log(JSON.stringify({ inputBottom, keyboardTop, measurement }));
ad("screenshot", resolve(artifacts, "keyboard.png"));
assert(
inputBottom <= keyboardTop + 1 / measurement.scale,
"Code size must be fully visible above the number keyboard (within one physical pixel)",
);
console.log("PASS: focusing Code size reveals the whole field above the keyboard");
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad(
"open",
appId,
`paseo://settings/hosts/${encodeURIComponent(serverId)}/plugins`,
"--platform",
"android",
"--no-test-ime",
);
ad("wait", "text", "Plugin source", "5000");
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
assert(source, "The connected host must support installing plugins from source");
ad("press", `@${source.ref}`);
ad("type", "qa-source");
const pluginMeasurement = await waitForFocusedInput();
// A FormTextInput draws its border and padding on the native input's parent.
// Measure that visible frame, rather than accepting only the text as visible.
const hierarchy = JSON.parse(ad("snapshot", "--json")).data.nodes;
const focused = hierarchy.find((node) => node.type === "android.widget.EditText" && node.focused);
assert(focused, "Plugin source must remain focused after typing");
assert.equal(focused.value, "qa-source", "Typed source must remain in the focused field");
const frame = hierarchy.find((node) => node.index === focused.parentIndex);
assert(frame, "The source input's visible frame must be present in the native hierarchy");
assert(
frame.rect.height > focused.rect.height,
"Measure the surrounding frame, including padding",
);
const frameBottom = frame.rect.y + frame.rect.height;
const pluginKeyboardTop = pluginMeasurement.keyboard.screenY * pluginMeasurement.scale;
console.log(JSON.stringify({ frameBottom, keyboardTop: pluginKeyboardTop, frame: frame.rect }));
ad("screenshot", resolve(artifacts, "plugin-source-keyboard.png"));
assert(
frameBottom <= pluginKeyboardTop + 1,
"Plugin source frame must be fully visible above the keyboard (within one physical pixel)",
);
console.log("PASS: focusing Plugin source reveals the whole field frame above the keyboard");
} finally {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
evaluate("delete globalThis.__appearanceKeyboardMeasurement");
}
@@ -0,0 +1,47 @@
# Late composer pill clearance
This regression opens a completed conversation, relaunches the Android app, and
registers a composer pill five seconds after the client plugin loads. It checks
that the completed turn's Copy and Fork buttons remain fully above the pill.
The daemon, plugin API, native app, layout, and accessibility bounds are real;
no provider calls are needed during the test.
Prerequisites:
- Build the server and app dependencies with `npm run build:server` and
`npm run build --workspace=@getpaseo/expo-two-way-audio`.
- Run an isolated development daemon on a non-default port.
- Run this checkout's Metro server and open the connected Android dev client
with `agent-device`. The app must already know the isolated daemon.
- Open a completed conversation on a compact portrait Android emulator, at the
latest turn, with no other composer tracks visible. Keep the software keyboard
closed. Note the workspace and agent IDs from the daemon.
Set `WORKSPACE_ID` and `AGENT_ID` to that conversation, then run from the
repository root, substituting the session and ports:
```sh
PASEO_OVERLAY_ARTIFACTS_DIR=.dev/composer-overlay-evidence \
node packages/app/e2e/mobile/composer-overlay/android.mjs \
composer-overlay ws://127.0.0.1:6843/ws "$WORKSPACE_ID" "$AGENT_ID" sh.paseo.debug 8193
```
The runner installs and removes its delayed plugin and leaves the conversation
intact. It preserves the daemon's previous plugins-enabled setting. The evidence directory
contains the cold-reopen screenshot and raw accessibility snapshot. Without an
explicit directory, temporary evidence is removed with the fixture.
To check an existing capture independently:
```sh
node packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs \
.dev/composer-overlay-evidence/cold-reopen.json 'Overlay regression pill'
```
On the failing baseline, the Copy button extends below the pill's top edge. With
the correction, both actions are clear before the composer receives focus.
Additional checks on the same conversation: opening the keyboard keeps the footer
clear; after scrolling up, a newly registered pill must not jump back to the latest
turn. Native iOS uses a different keyboard implementation and needs its own device
verification.
@@ -0,0 +1,95 @@
// Start on a completed conversation in the connected Android dev client.
// node android.mjs <session> <daemon-ws-url> <workspace-id> <agent-id> [app-id] [metro-port]
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocket } from "ws";
import { DaemonClient } from "../../../../client/dist/daemon-client.js";
const [, , session, daemonUrl, workspaceId, agentId, appId = "sh.paseo.debug", metroPort = "8081"] =
process.argv;
assert(
session && daemonUrl && workspaceId && agentId,
"Pass a session, isolated daemon URL, workspace ID, and agent ID",
);
const url = new URL(daemonUrl);
assert(["127.0.0.1", "localhost"].includes(url.hostname), "Use an isolated loopback daemon");
assert(!["", "6767", "6768"].includes(url.port), "Do not use the installed or development daemon");
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-overlay-"));
const artifacts = process.env.PASEO_OVERLAY_ARTIFACTS_DIR ?? path.join(directory, "evidence");
await mkdir(artifacts, { recursive: true });
const pluginId = "composer-overlay-mobile-repro";
const pillLabel = "Overlay regression pill";
const client = new DaemonClient({
url: daemonUrl,
clientId: "composer-overlay-mobile-regression",
clientType: "cli",
appVersion: JSON.parse(await readFile(new URL("../../../package.json", import.meta.url))).version,
webSocketFactory: (address, options) => new WebSocket(address, options),
});
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 90_000,
});
}
let installed = false;
let previousConfig;
try {
ad("wait", 'label="Copy turn"', "20000");
await client.connect();
previousConfig = (await client.getDaemonConfig()).config;
await client.patchDaemonConfig({ pluginsEnabled: true });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: pluginId,
requirements: { paseo: ">=0.11.4" },
}),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`export default function contribute(client) {
let pill;
const timer = setTimeout(() => {
pill = client.addComposerPill({
id: "late-pill", workspaceId: ${JSON.stringify(workspaceId)}, agentId: ${JSON.stringify(agentId)},
button: { title: ${JSON.stringify(pillLabel)}, icon: "Clock", behavior: { kind: "action", onPress() {} } },
});
}, 5000);
return () => { clearTimeout(timer); pill?.remove(); };
}`,
);
await client.installDirectoryPlugin(directory);
installed = true;
ad("wait", `label="${pillLabel}"`, "20000");
// Match the report: reopen a completed conversation, then let the plugin register late.
ad(
"open",
appId,
"--platform",
"android",
"--metro-port",
metroPort,
"--no-test-ime",
"--relaunch",
);
ad("wait", `label="${pillLabel}"`, "20000");
const capture = path.join(artifacts, "cold-reopen.json");
await writeFile(capture, ad("snapshot", "--raw", "--json"));
ad("screenshot", path.join(artifacts, "cold-reopen.png"));
execFileSync(
process.execPath,
[fileURLToPath(new URL("./assert-footer-clear.mjs", import.meta.url)), capture, pillLabel],
{ stdio: "inherit" },
);
} finally {
if (installed) await client.removePlugin(pluginId);
if (previousConfig)
await client.patchDaemonConfig({ pluginsEnabled: previousConfig.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
}
@@ -0,0 +1,21 @@
// Assert geometry from a fresh agent-device snapshot --raw --json capture.
// node assert-footer-clear.mjs <snapshot.json> <composer-pill-label>
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const [, , snapshotPath, pillLabel] = process.argv;
assert(snapshotPath && pillLabel, "Pass a snapshot JSON path and composer pill label");
const { data } = JSON.parse(readFileSync(snapshotPath, "utf8"));
const buttons = data.nodes.filter((node) => node.type === "android.widget.Button");
const pill = buttons.find((node) => node.label === pillLabel);
assert(pill?.rect, `Composer pill '${pillLabel}' must be visible`);
for (const label of ["Copy turn", "Fork chat from here"]) {
const button = buttons.find((node) => node.label === label);
assert(button?.rect, `Completed-turn action '${label}' must be visible`);
const bottom = button.rect.y + button.rect.height;
assert(
bottom <= pill.rect.y,
`${label} ends at ${bottom}, below composer pill top ${pill.rect.y}`,
);
}
console.log("PASS: completed-turn actions remain fully above the composer pill");
@@ -0,0 +1,45 @@
# Native retained status-ring regression
This runs the production native ring and clock inside real retained panels with
Reanimated on Android. A probe consumes the same public rotation hook and counts
UI-runtime value changes. No Reanimated mocks or provider processes are used.
Use an Android development client compatible with the checkout's native dependencies.
In a disposable checkout, temporarily replace `packages/app/index.ts` with:
```tsx
import "./src/polyfills/install-early";
import "./src/styles/unistyles";
import { registerRootComponent } from "expo";
import NativeRingRegression from "./e2e/mobile/status-ring/native";
registerRootComponent(NativeRingRegression);
```
Start Metro in `packages/app`:
```sh
APP_VARIANT=development npx expo start --localhost --port 8096
```
With one Android emulator running, open that bundle:
```sh
agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096
```
From the repository root:
```sh
node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096
```
The test checks visible rotation, hidden-panel inactivity while another ring is
visible, inactivity with both panels hidden, and synchronized resumption. Each
sample reads counters on the UI runtime; elapsed time includes device-command
latency. The baseline fails at the first hidden-panel assertion.
Restore the original `packages/app/index.ts`, close the agent-device session,
and stop Metro afterward. The fixture substitutes running rings for a live agent
and toggles retained panels directly; it does not exercise workspace navigation
or measure whole-app CPU usage. It is excluded from routine unit tests because
it requires a native runtime.
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl] = process.argv;
assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx");
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim();
return value === "undefined" ? undefined : JSON.parse(value);
}
let target;
const deadline = Date.now() + 30_000;
do {
target = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.find((line) => line.includes("sh.paseo.debug"));
if (!target) await delay(250);
} while (!target && Date.now() < deadline);
assert(target, "Open the Android dev client on this Metro bundle first");
ad("cdp", "target", "select", target.split(/\s+/)[0]);
let ready = false;
do {
ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2");
if (!ready) await delay(250);
} while (!ready && Date.now() < deadline);
assert(ready, "The two native probes must mount before sampling");
async function sample() {
evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())");
await delay(100);
return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)"));
}
async function observe(label) {
const before = await sample();
await delay(600);
const after = await sample();
const deltas = Object.fromEntries(
Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]),
);
console.log(JSON.stringify({ label, deltas, after }));
return { deltas, after };
}
const visible = await observe("both visible");
assert(visible.deltas.first > 0 && visible.deltas.second > 0);
assert.equal(visible.after.first.rotation, visible.after.second.rotation);
ad("press", 'label="HIDE FIRST"');
await delay(200);
const hidden = await observe("first hidden, second visible");
assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates");
assert(hidden.deltas.second > 0, "Visible rings must continue rotating");
ad("press", 'label="HIDE SECOND"');
await delay(200);
const allHidden = await observe("both hidden");
assert.equal(allHidden.deltas.first, 0);
assert.equal(allHidden.deltas.second, 0);
ad("press", 'label="SHOW FIRST"');
ad("press", 'label="SHOW SECOND"');
await delay(200);
const restored = await observe("both shown again");
assert(restored.deltas.first > 0 && restored.deltas.second > 0);
assert.equal(
restored.after.first.rotation,
restored.after.second.rotation,
"Restored rings must share the current phase",
);
console.log("PASS native retained-ring lifecycle");
@@ -0,0 +1,69 @@
import { useCallback, useLayoutEffect, useState } from "react";
import { Button, Text, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
import { RetainedPanel } from "@/components/retained-panel";
import { useStatusRingRotation } from "@/components/status-ring/clock";
import { StatusRing } from "@/components/status-ring";
interface Sample {
updates: number;
rotation: number;
}
declare global {
var __ringSamples: Record<string, Sample>;
var __ringReaders: Record<string, () => void>;
}
function recordSample(name: string, value: { updates: number; rotation: number }) {
globalThis.__ringSamples[name] = value;
}
function Probe({ name }: { name: string }) {
const rotation = useStatusRingRotation();
const updates = useSharedValue(0);
useAnimatedReaction(
() => rotation.value,
() => {
updates.value += 1;
},
);
// Read the real UI runtime, without scheduling a continuous JS observer.
useLayoutEffect(() => {
globalThis.__ringSamples ??= {};
globalThis.__ringReaders ??= {};
globalThis.__ringReaders[name] = () =>
scheduleOnUI(() => {
"worklet";
const sample = { updates: updates.value, rotation: rotation.value };
scheduleOnRN(recordSample, name, sample);
});
return () => {
delete globalThis.__ringReaders[name];
};
}, [name, rotation, updates]);
return <StatusRing />;
}
const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" };
export default function NativeRingRegression() {
const [first, setFirst] = useState(true);
const [second, setSecond] = useState(true);
const toggleFirst = useCallback(() => setFirst((value) => !value), []);
const toggleSecond = useCallback(() => setSecond((value) => !value), []);
return (
<View style={containerStyle}>
<Text>Native retained status rings</Text>
<Button title={first ? "Hide first" : "Show first"} onPress={toggleFirst} />
<Button title={second ? "Hide second" : "Show second"} onPress={toggleSecond} />
<RetainedPanel active={first}>
<Probe name="first" />
</RetainedPanel>
<RetainedPanel active={second}>
<Probe name="second" />
</RetainedPanel>
</View>
);
}
+21
View File
@@ -0,0 +1,21 @@
/** @param {import('@playwright/test').Page} page */
export async function hideDeveloperToasts(page) {
// Keep developer chrome out of app interactions without Playwright locator
// handlers: parallel app assertions can race while dismissing the same toast.
// Console errors remain in the runner's logs.
function hideToasts() {
const installStyle = () => {
const style = document.createElement("style");
style.textContent = "#error-toast { display: none !important; }";
document.head.appendChild(style);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", installStyle, { once: true });
} else {
installStyle();
}
}
await page.addInitScript(hideToasts);
// Electron connects to an already loaded page; apply there as well.
await page.evaluate(hideToasts);
}
+3
View File
@@ -1,4 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test";
import { hideDeveloperToasts } from "./developer-ui.mjs";
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
import { startE2EWorker } from "./helpers/e2e-worker";
import { getE2EDaemonPort } from "./helpers/daemon-port";
@@ -130,6 +131,8 @@ const test = daemonTest.extend<{
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await hideDeveloperToasts(page);
const entries: string[] = [];
page.on("console", (message) => {
@@ -1,79 +0,0 @@
import { defineRpc } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { runAcpProvider, type AcpStreamMessage } from "@getpaseo/plugin/server/acp";
import { z } from "zod";
/** A single-turn producer controlled through the daemon, before timeline persistence. */
export default function contribute(server: PluginServerContext) {
let startTurn!: (append: (chunks: string[], finish: boolean) => void) => void;
const turn = new Promise<(chunks: string[], finish: boolean) => void>((resolve) => {
startTurn = resolve;
});
server.handle(
defineRpc({
name: "append",
input: z.object({ chunks: z.array(z.string()), finish: z.boolean() }),
output: z.null(),
}),
async ({ chunks, finish }) => {
const append = await turn;
append(chunks, finish);
return null;
},
);
server.registerProvider(
runAcpProvider({ id: "controlled-stream", label: "Controlled stream", connector: connect }),
);
return () => {};
function connect() {
let controller: ReadableStreamDefaultController<AcpStreamMessage>;
return {
readable: new ReadableStream<AcpStreamMessage>({
start(value) {
controller = value;
},
}),
writable: new WritableStream<AcpStreamMessage>({
write(message) {
if (!("method" in message) || !("id" in message)) return;
if (message.method === "session/prompt") {
let finished = false;
startTurn((chunks, finish) => {
if (finished) return;
for (const text of chunks) {
controller.enqueue({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "controlled",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text },
},
},
});
}
if (finish) {
finished = true;
controller.enqueue({
jsonrpc: "2.0",
id: message.id,
result: { stopReason: "end_turn" },
});
}
});
return;
}
let result: unknown = {};
if (message.method === "initialize") {
result = { protocolVersion: 1, agentCapabilities: {} };
} else if (message.method === "session/new") {
result = { sessionId: "controlled" };
}
controller.enqueue({ jsonrpc: "2.0", id: message.id, result });
},
}),
};
}
}
@@ -1 +0,0 @@
{ "id": "controlled-stream-test", "requirements": { "paseo": ">=0.8.0" } }
+2
View File
@@ -134,6 +134,8 @@ function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>):
env: {
...process.env,
BROWSER: "none",
// Automation never opens native DevTools or installs its desktop shell.
EXPO_UNSTABLE_HEADLESS: "1",
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_WEB_PLATFORM: "electron" } : {}),
},
stdio: ["ignore", "pipe", "pipe"],
@@ -203,7 +203,8 @@ export async function expectOneLineTruncatedFileResult(
expect(lineBox).not.toBeNull();
expect(filenameBox).not.toBeNull();
expect(pathBox).not.toBeNull();
expect(rowBox?.height).toBe(36);
// COMMAND_CENTER_LIST_METRICS.desktop.singleLineRow (list-metrics imports react-native).
expect(rowBox?.height).toBe(32);
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
expect(overflow.overflow).toBe("hidden");
+61 -4
View File
@@ -363,10 +363,11 @@ export async function resizeComposerViewport(
await page.setViewportSize(sizes[orientation]);
// Before the app has loaded there is no layout to wait for.
if (page.url() === "about:blank") return;
// Crossing the compact breakpoint remounts the shell and the editor. Anything that
// follows (a shortcut, a locator) belongs to the layout the new size implies, so
// wait for that layout before returning.
await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
// The shell and editor survive the breakpoint. Wait for the shell's layout
// marker to update before interacting with the resized composer.
await expect(
page.getByTestId(orientation === "landscape" ? "wide-shell" : "compact-shell"),
).toBeVisible();
}
export async function selectComposerDraftRange(
page: Page,
@@ -501,3 +502,59 @@ export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
}
/** Exercise the quick-return race before a hidden pane gets a rendering frame. */
export async function roundTripComposerTabBeforePaint(
page: Page,
activeChatTitle: string,
): Promise<void> {
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
await expect(activeTab).toHaveAttribute("aria-selected", "true");
const result = await activeTab.evaluate((tab) => {
const editor = document.activeElement;
return new Promise<{ returnedBeforePaint: boolean; focused: boolean }>((resolve) => {
let switchedAway = false;
let painted = false;
let frame = 0;
const shortcut = (code: "BracketLeft" | "BracketRight") => {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: code === "BracketLeft" ? "{" : "}",
code,
altKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
}),
);
};
// Watch selection commits without reading layout: a layout read could make
// Chromium blur the hidden editor and erase the condition under test.
const observer = new MutationObserver(() => {
if (!switchedAway && tab.getAttribute("aria-selected") === "false") {
switchedAway = true;
frame = requestAnimationFrame(() => {
painted = true;
});
// Deliver the return shortcut before rendering, without a Playwright
// round trip allowing the browser to paint the inactive tab first.
shortcut("BracketRight");
} else if (switchedAway && tab.getAttribute("aria-selected") === "true") {
observer.disconnect();
cancelAnimationFrame(frame);
return resolve({
returnedBeforePaint: !painted,
focused: document.activeElement === editor,
});
}
});
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
shortcut("BracketLeft");
});
});
expect(
result.returnedBeforePaint,
"tab round trip must finish before rendering the hidden pane",
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
@@ -37,7 +37,7 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 });
const card = contextWindowDetails(page);
await expect(card).toBeVisible();
await expect(card).toBeInViewport();
await expect(card).toBeInViewport({ ratio: 1 });
return card;
}
@@ -1,57 +0,0 @@
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "./daemon-client-loader";
type ControlledStreamClient = Pick<
DaemonClient,
| "connect"
| "close"
| "getDaemonConfig"
| "patchDaemonConfig"
| "installDirectoryPlugin"
| "removePlugin"
| "invokePluginRpc"
>;
/** Owns one real provider turn whose completion the test explicitly releases. */
export async function createControlledStream() {
const client = await connectDaemonClient<ControlledStreamClient>({
clientIdPrefix: "controlled-stream",
});
const previous = await client.getDaemonConfig();
let appended = false;
const emit = (chunks: string[], finish: boolean) =>
client.invokePluginRpc("controlled-stream-test", "append", { chunks, finish });
const cleanup = async () => {
try {
if (appended) await emit([], true);
await client.removePlugin("controlled-stream-test");
} finally {
try {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
} finally {
await client.close();
}
}
};
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(
path.resolve(__dirname, "../fixtures/controlled-stream-plugin"),
);
return {
provider: "controlled-stream",
append: async (chunks: string[]) => {
await emit(chunks, false);
appended = true;
},
finish: async (chunks: string[] = []) => {
await emit(chunks, true);
},
cleanup,
};
} catch (error) {
await cleanup();
throw error;
}
}
@@ -24,6 +24,7 @@ interface E2EDaemonClientConfig {
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
capabilities?: Record<string, unknown>;
}
function resolveDaemonWsUrl(port?: number): string {
@@ -34,6 +35,7 @@ export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
port?: number;
capabilities?: Record<string, unknown>;
}
/**
@@ -51,6 +53,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
clientType: "cli",
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
capabilities: options.capabilities,
});
await client.connect();
return client;
@@ -134,3 +134,19 @@ export async function expectProjectSettingsName(page: Page, projectName: string)
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectWorkspace(
page: Page,
projectName: string,
workspaceName: string,
): Promise<void> {
await projectGroup(page, projectName)
.getByRole("button")
.filter({ hasText: workspaceName })
.click();
await expect(page).toHaveURL(/\/workspace\//);
}
export async function expectWorkspaceHost(page: Page, hostName: string): Promise<void> {
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
}
@@ -1,175 +0,0 @@
import { expect, test } from "vitest";
import { findScrollJumps, type ScrollFrame } from "./scroll-jumps";
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
const before: ScrollFrame = {
at: 1000,
scrollTop: 1000,
scrollHeight: 5000,
viewportHeight: 800,
virtualized: true,
loading: false,
rows: [
{ id: "image", top: -150, height: 150 },
{ id: "reading", top: 0, height: 100 },
],
anchor: "reading",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 0,
unmounted: 0,
};
const after: ScrollFrame = {
...before,
at: 1400,
scrollTop: 900,
wheelTotal: 100,
lastWheelAt: 1050,
rows: [
{ id: "image", top: -50, height: 1150 },
{ id: "reading", top: 1100, height: 100 },
],
};
expect(findScrollJumps([before, after])).toEqual([]);
expect(
findScrollJumps([
{ ...before, scrollTop: 200 },
{
...after,
wheelTotal: 1000,
scrollTop: 0,
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
},
]),
).toEqual([]);
expect(
findScrollJumps([
before,
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
]),
).toHaveLength(1);
expect(
findScrollJumps([
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
]),
).toHaveLength(1);
});
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
const before: ScrollFrame = {
at: 1000,
scrollTop: 2000,
scrollHeight: 5000,
viewportHeight: 800,
virtualized: true,
loading: false,
rows: [
{ id: "new-reader", top: -520, height: 278 },
{ id: "image", top: -242, height: 184 },
{ id: "old-reader", top: -58, height: 130 },
],
anchor: "old-reader",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 0,
unmounted: 0,
};
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
const after: ScrollFrame = {
...wheel,
at: 1220,
scrollTop: 1520,
scrollHeight: 5041,
anchor: "new-reader",
rows: [
{ id: "new-reader", top: -40, height: 278 },
{ id: "image", top: 238, height: 225 },
{ id: "old-reader", top: 463, height: 130 },
],
};
expect(findScrollJumps([before, wheel, after])).toEqual([]);
// The previous 480 px input has already moved the viewport; the recent
// budget includes it, but only the next 480 px belongs to this frame.
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
const steadyAfter = {
...after,
at: 1100,
wheelTotal: 960,
lastWheelAt: 1099,
scrollHeight: 6000,
rows: [
{ id: "new-reader", top: -40, height: 278 },
{ id: "image", top: 238, height: 1184 },
{ id: "old-reader", top: 1422, height: 130 },
],
};
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
expect(
findScrollJumps([
before,
wheel,
{
...after,
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
},
]),
).toHaveLength(1);
});
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
for (const height of [225, 1632]) {
const before: ScrollFrame = {
at: 1000,
scrollTop: 2208,
scrollHeight: 5000,
viewportHeight: 516,
virtualized: true,
loading: false,
rows: [
{ id: "image", top: -704, height: 560 },
{ id: "caption", top: -144, height: 100 },
{ id: "old-reader", top: -44, height: 127 },
],
anchor: "old-reader",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 3,
unmounted: 0,
};
const resize = height - 560;
const after: ScrollFrame = {
...before,
at: 1060,
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
scrollTop: 1938,
scrollHeight: 5000 + resize - 110,
wheelTotal: 160,
lastWheelAt: 1020,
imageLoads: 1,
rows: [
{ id: "image", top: -544, height },
{ id: "caption", top: 16 + resize, height: 100 },
{ id: "old-reader", top: 116 + resize, height: 127 },
],
};
expect(findScrollJumps([before, after])).toEqual([]);
// Resizing must not excuse additional viewport movement.
expect(
findScrollJumps([
before,
{
...after,
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
},
]),
).toHaveLength(1);
}
});

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