Compare commits

..
Author SHA1 Message Date
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
188 changed files with 6554 additions and 1573 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 }}
# Weight file groups by observed duration; raw test counts overload
# early agent/Changes specs. Required check names remain unchanged.
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
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": {
+29 -6
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.12.0-beta.2 - 2026-10-17
## 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,12 @@ 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))
- 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))
- 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))
@@ -41,6 +45,9 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- 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
@@ -54,11 +61,18 @@ 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))
@@ -68,7 +82,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- 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 ([#6242](https://github.com/getpaseo/paseo/pull/6242))
- 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))
@@ -90,8 +104,13 @@ 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))
@@ -102,7 +121,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- 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 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 ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
- 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))
@@ -147,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 -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.
+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.
+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
+1 -1
View File
@@ -1 +1 @@
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/server": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,10 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37979,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38904,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38921,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38944,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/highlight": "0.12.0-beta.2",
"@getpaseo/plugin": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0",
"@getpaseo/highlight": "0.12.0",
"@getpaseo/plugin": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40119,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41727,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -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");
@@ -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();
@@ -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("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").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);
+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();
}
@@ -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);
});
@@ -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");
});
@@ -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"],
});
@@ -259,3 +259,50 @@ async function openInlineRegressionFile(page: Page, path: string) {
.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();
}
});
@@ -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");
});
@@ -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,
});
});
});
@@ -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();
}
+65 -1
View File
@@ -16,6 +16,7 @@ import {
} from "../support/helpers/usage-reports";
import {
installLoginUsage,
expectLoginUsageFreshness,
installCodexWindowUsage,
expectCodexReportedWindows,
refreshLoginUsage,
@@ -588,6 +589,69 @@ 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.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 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%");
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%");
await expect(page.getByText("42%", { 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%(?:$| ·)/)).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
}
@@ -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,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");
@@ -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");
@@ -501,3 +501,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);
}
@@ -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);
}
@@ -135,3 +135,14 @@ export async function expectCodexReportedWindows(page: Page, shape: "seven-day-o
);
}
}
export async function expectLoginUsageFreshness(page: Page, text: string, compact: boolean) {
const card = page.getByTestId("usage-report-login-journey:account");
if (compact) {
await expect(card.getByTestId("usage-freshness")).toHaveText(text);
return;
}
await card.getByRole("button", { name: "Refresh Claude", exact: true }).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText(text);
await page.mouse.move(0, 0);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0-beta.2",
"version": "0.12.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
describe("native scroll-to-bottom animation", () => {
it("jumps across unmounted history instead of animating through blank rows", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }),
).toBe(false);
});
it("keeps near-bottom movement eligible for animation", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }),
).toBe(true);
});
it("bounds travel by the bottom-side overscan, not the whole window", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }),
).toBe(true);
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }),
).toBe(false);
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }),
).toBe(false);
});
it("does not animate before the viewport has been measured", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }),
).toBe(false);
});
});
@@ -0,0 +1,10 @@
export function shouldAnimateNativeScrollToBottom(input: {
offsetY: number;
viewportHeight: number;
windowSize: number;
}): boolean {
// VirtualizedList splits the extra window around the viewport. An animated
// jump farther than the bottom-side overscan crosses unmounted history.
const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight;
return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight;
}
@@ -23,6 +23,7 @@ import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useKeyboardStreamInset } from "@/keyboard/shift";
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
import { useRevisedHistoryRows } from "./history-row-revision";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
@@ -64,6 +65,7 @@ const historyStartSlotStyle: ViewStyle = {
flexShrink: 0,
};
const HISTORY_START_SETTLE_FRAMES = 2;
const NATIVE_STREAM_WINDOW_SIZE = 10;
function keyExtractor(item: { id: string }): string {
return item.id;
@@ -238,7 +240,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToOffset({
offset: 0,
animated,
animated:
animated &&
shouldAnimateNativeScrollToBottom({
offsetY: scrollOffsetYRef.current,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
windowSize: NATIVE_STREAM_WINDOW_SIZE,
}),
});
scrollOffsetYRef.current = 0;
streamViewportMetricsRef.current = {
@@ -591,11 +599,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
// A history row can contain an entire long reply. Keep synchronous native
// Markdown measurement bounded while FlatList fills the surrounding window.
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
initialNumToRender={2}
maxToRenderPerBatch={2}
windowSize={5}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
+8 -1
View File
@@ -771,6 +771,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={data.status}
detail={data.detail}
cwd={context.cwd}
serverId={resolvedServerId}
metadata={data.metadata}
agentMessage={data.agentMessage}
isLastInSequence={isLastInSequence}
@@ -795,7 +796,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
/>
);
},
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
[context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile],
);
// Read through a stable event so live group updates do not change the renderer identity
@@ -1251,6 +1252,12 @@ function agentStreamViewPropsEqual(
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
reasons.push("bottomOverlayTailClearance");
}
if (left.bottomOverlayControlClearance !== right.bottomOverlayControlClearance) {
reasons.push("bottomOverlayControlClearance");
}
if (left.toast !== right.toast) reasons.push("toast");
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M2.033 9.867l2.554 1.483a.589.589 0 00.592 0l2.554-1.483.01-.008a.608.608 0 00.11-.084l.013-.015a.631.631 0 00.076-.1c.003-.005.008-.01.01-.016a.558.558 0 00.052-.125l.007-.028a.611.611 0 00.019-.14V7.868c0-.572.307-1.105.8-1.392a1.595 1.595 0 011.598 0l1.277.742a.54.54 0 00.129.053l.028.01c.044.01.088.015.133.016h.006l.013-.002a.587.587 0 00.27-.074l.011-.004 2.554-1.483a.596.596 0 00.297-.516V2.253a.595.595 0 00-.297-.516L12.293.257a.587.587 0 00-.591 0L9.148 1.737l-.01.01a.609.609 0 00-.109.083l-.014.015a.632.632 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.028a.612.612 0 00-.018.14v1.483c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.603.603 0 00-.13-.053l-.028-.008a.658.658 0 00-.133-.018h-.02a.57.57 0 00-.269.074c-.003.002-.008.002-.012.005L2.033 5.872a.596.596 0 00-.297.515v2.966c0 .213.113.41.297.515z" fill="currentColor"></path><path d="M15.943 10.607a1.596 1.596 0 011.599 0l1.276.74c.041.025.085.04.13.055l.028.008c.043.01.088.016.133.018h.005c.005 0 .01-.002.014-.003a.474.474 0 00.122-.016l.021-.005a.616.616 0 00.126-.052c.004-.002.009-.002.013-.005l2.554-1.482a.597.597 0 00.297-.516V6.383a.596.596 0 00-.297-.515l-2.552-1.483a.587.587 0 00-.592 0l-2.553 1.482-.011.008a.61.61 0 00-.108.084l-.014.016a.637.637 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.029a.612.612 0 00-.018.14v1.482c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.584.584 0 00-.13-.053l-.028-.008a.62.62 0 00-.133-.018h-.02a.587.587 0 00-.269.074l-.012.004L9.15 10a.596.596 0 00-.296.516v2.966c0 .212.112.409.296.515l2.554 1.483s.008.002.012.005c.04.022.082.04.126.052l.02.004a.57.57 0 00.123.017l.014.002h.006c.054 0 .108-.01.16-.025a.587.587 0 00.13-.054l1.277-.741a1.597 1.597 0 012.398 1.392v1.482c0 .049.007.095.019.14l.007.028a.619.619 0 00.051.125c.004.006.008.01.01.016a.6.6 0 00.076.1l.014.015c.033.032.069.06.108.084.004.002.006.006.011.008l2.554 1.483a.59.59 0 00.593 0l2.554-1.483a.597.597 0 00.296-.516v-2.965a.595.595 0 00-.296-.516l-2.554-1.483s-.008-.002-.012-.005a.54.54 0 00-.126-.051c-.007-.003-.013-.003-.02-.005a.635.635 0 00-.125-.017h-.018a.557.557 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.595 1.595 0 01-1.598 0 1.615 1.615 0 010-2.785l-.005-.001z" fill="currentColor"></path><path d="M14.848 18.265l-2.554-1.482-.012-.005a.526.526 0 00-.126-.052c-.007-.002-.014-.002-.02-.005a.64.64 0 00-.124-.017h-.02a.56.56 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.594 1.594 0 01-1.598 0c-.493-.286-.8-.82-.8-1.393V14.65a.563.563 0 00-.018-.14l-.008-.028a.604.604 0 00-.051-.124l-.01-.017a.603.603 0 00-.076-.1l-.014-.015a.596.596 0 00-.109-.084c-.003-.002-.005-.006-.01-.008L5.178 12.65a.587.587 0 00-.591 0l-2.554 1.483a.596.596 0 00-.297.516v2.965c0 .213.113.41.297.516l2.554 1.483.012.004a.618.618 0 00.267.074l.016.002h.007a.55.55 0 00.16-.026.584.584 0 00.129-.053l1.277-.742a1.597 1.597 0 012.398 1.393v1.482c0 .05.007.095.019.14l.007.028c.013.044.03.085.051.125l.01.016c.022.036.047.07.076.1l.014.015c.032.032.069.06.109.084l.01.008 2.554 1.483a.587.587 0 00.593 0l2.554-1.483a.596.596 0 00.296-.515v-2.966a.596.596 0 00-.296-.516h-.002z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">
<path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>
<path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M18.8 0H5.2A5.2 5.2 0 000 5.2v13.6A5.2 5.2 0 005.2 24h13.6a5.2 5.2 0 005.2-5.2V5.2A5.2 5.2 0 0018.8 0z" fill="#9046FF" /> <path d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001z" fill="#fff" /> <path d="M12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z" fill="#000" /></svg>

After

Width:  |  Height:  |  Size: 895 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="currentColor" fill-rule="nonzero"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 334 B

+230 -119
View File
@@ -16,7 +16,10 @@ import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
EditingTextInput as TextInput,
@@ -29,8 +32,11 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { createProjectIconTarget } from "@/projects/icon-target";
import { useProjectIcons } from "@/projects/icons";
import {
OverlayLayerProvider,
useGlobalWebOverlayLayer,
@@ -49,8 +55,14 @@ import {
} from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import {
COMMAND_CENTER_LINE_HEIGHT,
COMMAND_CENTER_LIST_METRICS,
COMMAND_CENTER_SECTION_GAP,
type CommandCenterListMetrics,
} from "./list-metrics";
import { useCommandCenterContributions } from "./provider";
import { filterAndRankWorkspaces } from "./workspace-search";
import {
@@ -89,42 +101,14 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
const leftAttention = left.requiresAttention ? 1 : 0;
const rightAttention = right.requiresAttention ? 1 : 0;
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
const leftRunning = left.status === "running" ? 1 : 0;
const rightRunning = right.status === "running" ? 1 : 0;
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
}
function compareWorkspacesByTitle(
left: CommandCenterWorkspaceResult,
right: CommandCenterWorkspaceResult,
): number {
const titleDelta = left.title.localeCompare(right.title, undefined, {
numeric: true,
sensitivity: "base",
});
return titleDelta || left.subtitle.localeCompare(right.subtitle);
}
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
/**
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
* collation sort over every workspace.
*
* The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps
* now refresh when agents or projects change rather than on every keystroke.
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
*/
function useBuiltInRows(open: boolean): {
workspaces: CommandCenterWorkspaceResult[];
@@ -134,11 +118,32 @@ function useBuiltInRows(open: boolean): {
const { agents } = useAggregatedAgents({ demand: open });
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
const iconTargets = useMemo(
() =>
open
? projects.flatMap((project) =>
project.hosts.flatMap((host) => {
const target = createProjectIconTarget({
projectViewKey: project.viewKey,
placement: { ...host, iconWorkingDir: host.repoRoot },
});
return target ? [target] : [];
}),
)
: [],
[open, projects],
);
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
return useMemo(() => {
if (!open) return { workspaces: [], agents: [] };
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
for (const project of projects) {
const workspaceProject = {
viewKey: project.viewKey,
name: project.projectName,
iconDataUri: iconDataByProjectViewKey.get(project.viewKey) ?? null,
};
for (const host of project.hosts) {
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
@@ -151,7 +156,9 @@ function useBuiltInRows(open: boolean): {
project.projectName,
workspace.currentBranch,
]),
project: workspaceProject,
changeRequestNumber: workspace.changeRequestNumber,
activityAt: workspace.statusEnteredAt,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
@@ -160,34 +167,31 @@ function useBuiltInRows(open: boolean): {
}
}
}
allWorkspaces.sort(compareWorkspacesByTitle);
const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
);
const agentRows = agents
.map<CommandCenterAgentResult>((agent) => {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: agent.title || t("shell.commandCenter.newAgent"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
workspaceTitle ?? shortenPath(agent.cwd),
formatTimeAgo(agent.lastActivityAt),
]),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
})
.sort((left, right) => sortAgents(left.agent, right.agent));
const agentRows = agents.map<CommandCenterAgentResult>((agent) => {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: agent.title || t("shell.commandCenter.newAgent"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
workspaceTitle ?? shortenPath(agent.cwd),
]),
activityAt: agent.lastActivityAt,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
});
return { workspaces: allWorkspaces, agents: agentRows };
}, [agents, open, projects, showHost, t]);
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
@@ -202,16 +206,14 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
band: PINNED_SECTION_BAND,
rank: 2,
title: t("shell.commandCenter.workspaces"),
results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle),
results: filterAndRankWorkspaces(rows.workspaces, query),
},
{
id: "agents",
band: PINNED_SECTION_BAND,
rank: 3,
title: t("shell.commandCenter.agents"),
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) =>
sortAgents(left.agent, right.agent),
),
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields),
},
];
}, [open, query, rows, t]);
@@ -236,7 +238,7 @@ interface CommandCenterState {
key(key: string): boolean;
}
function useCommandCenterState(): CommandCenterState {
function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
@@ -290,8 +292,9 @@ function useCommandCenterState(): CommandCenterState {
scope === "files"
? fileSections
: [...contributionSections, ...fileSections, ...builtInSections],
listMetrics,
),
[builtInSections, contributionSections, fileSections, scope],
[builtInSections, contributionSections, fileSections, listMetrics, scope],
);
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
@@ -381,11 +384,19 @@ function useCommandCenterState(): CommandCenterState {
interface ResultRowProps {
result: CommandCenterResult;
height: number;
query: string;
active: boolean;
onSelect(result: CommandCenterResult): void;
}
const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) {
const ResultRow = memo(function ResultRow({
result,
height,
query,
active,
onSelect,
}: ResultRowProps) {
const press = useCallback(() => onSelect(result), [onSelect, result]);
const choice =
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
@@ -402,15 +413,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
const style = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(result.kind === "agent" ||
result.kind === "workspace" ||
(result.kind === "contribution" &&
result.contribution.presentation.kind === "action" &&
Boolean(result.contribution.presentation.subtitle))) &&
styles.tallRow,
{ height },
(Boolean(hovered) || pressed || active) && styles.activeRow,
],
[active, result],
[active, height],
);
return (
<Pressable
@@ -424,12 +430,75 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
}
>
<ResultContent result={result} />
<ResultContent result={result} query={query} />
</Pressable>
);
});
function ResultContent({ result }: { result: CommandCenterResult }) {
/**
* Text with the query's terms marked. Marks use the same matcher options the rows were ranked
* with, so a mark always points at something that made the row match.
*/
function QueryHighlightedText({
query,
text,
...props
}: { query: string } & Omit<HighlightedTextProps, "ranges">) {
const ranges = useMemo(() => findHighlightRanges(query, text), [query, text]);
return <HighlightedText text={text} ranges={ranges} {...props} />;
}
/**
* The subtitle line of a workspace or agent row. The timestamp is its own text so a long branch
* truncates without pushing the time out of view, and so the time is never part of what the
* query matches against. It keeps itself current while the command center stays open.
*/
function SubtitleWithTimestamp({
query,
subtitle,
activityAt,
testID,
}: {
query: string;
subtitle: string;
activityAt: Date | null;
testID: string;
}) {
const timeAgo = useTimeAgo(activityAt);
return (
<View style={styles.subtitleLine}>
<QueryHighlightedText
query={query}
text={subtitle}
style={[styles.subtitle, styles.subtitleText]}
numberOfLines={1}
testID={testID}
/>
{timeAgo ? (
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-result-timestamp">
{/* Non-breaking: web collapses a leading plain space in its own flex item. */}
{subtitle ? `\u00a0· ${timeAgo}` : timeAgo}
</Text>
) : null}
</View>
);
}
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
return (
<ProjectIconView
iconDataUri={project.iconDataUri}
initial={initial}
projectViewKey={project.viewKey}
size={16}
textStyle={styles.projectIconFallbackText}
/>
);
}
function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) {
if (result.kind === "file") {
return (
<View style={styles.rowContent}>
@@ -438,13 +507,16 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
<MaterialFileIcon fileName={result.title} size={16} />
</View>
<Text style={styles.fileLine} numberOfLines={1} testID="command-center-file-line">
<Text style={styles.fileName} testID="command-center-file-name">
{result.title}
</Text>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.fileName}
testID="command-center-file-name"
/>
{result.subtitle ? (
<Text style={styles.filePath} testID="command-center-file-path">
{" "}
{result.subtitle}
<QueryHighlightedText query={query} text={result.subtitle} />
</Text>
) : null}
</Text>
@@ -465,12 +537,18 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
/>
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{result.title}
</Text>
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-agent-subtitle">
{result.subtitle}
</Text>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-agent-subtitle"
/>
</View>
</View>
</View>
@@ -481,20 +559,28 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
return (
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<ThemedFolder size={16} strokeWidth={2.2} />
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
<View
style={styles.iconSlot}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
>
<WorkspaceProjectIcon project={result.project} />
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{result.title}
</Text>
<Text
style={styles.subtitle}
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-workspace-subtitle"
>
{result.subtitle}
</Text>
/>
</View>
</View>
</View>
@@ -512,11 +598,19 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
</View>
) : null}
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{presentation.title}
</Text>
<QueryHighlightedText
query={query}
text={presentation.title}
style={styles.title}
numberOfLines={1}
/>
{presentation.subtitle ? (
<Text style={styles.subtitle}>{presentation.subtitle}</Text>
<QueryHighlightedText
query={query}
text={presentation.subtitle}
style={styles.subtitle}
numberOfLines={1}
/>
) : null}
</View>
</View>
@@ -541,14 +635,14 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
style={styles.breadcrumbPart}
>
{index > 0 ? <ThemedChevronRight size={13} strokeWidth={2} /> : null}
<Text
<QueryHighlightedText
query={query}
text={part}
style={
index === presentation.path.length - 1 ? styles.title : styles.breadcrumbGroup
}
numberOfLines={1}
>
{part}
</Text>
/>
</View>
))}
</View>
@@ -563,11 +657,9 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
}
function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "section" }> }) {
let sizeStyle = styles.dividerSection;
if (row.title && row.divider) sizeStyle = styles.dividedSection;
if (row.title && !row.divider) sizeStyle = styles.titledSection;
const style = useMemo(() => [styles.section, { height: row.height }], [row.height]);
return (
<View style={sizeStyle}>
<View style={style}>
{row.divider ? <View style={styles.sectionDivider} /> : null}
{row.title ? <Text style={styles.sectionLabel}>{row.title}</Text> : null}
</View>
@@ -576,8 +668,10 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
export function CommandCenter() {
const { t } = useTranslation();
const state = useCommandCenterState();
const isCompact = useIsCompactFormFactor();
const state = useCommandCenterState(
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
);
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
@@ -644,11 +738,13 @@ export function CommandCenter() {
) : (
<ResultRow
result={item.result}
height={item.height}
query={state.query}
active={item.result.id === state.activeId}
onSelect={state.select}
/>
),
[state.activeId, state.select],
[state.activeId, state.query, state.select],
);
const getItemLayout = useCallback(
(_data: ArrayLike<CommandCenterListRow> | null | undefined, index: number) => ({
@@ -901,20 +997,21 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: "500",
},
results: { flex: 1 },
// Sections and rows take their height from list-metrics; these styles only place content in it.
section: { justifyContent: "flex-end" },
sectionLabel: {
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
paddingBottom: COMMAND_CENTER_SECTION_GAP,
fontSize: theme.fontSize.sm,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.sectionLabel,
color: theme.colors.foregroundMuted,
},
sectionDivider: {
height: 1,
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
marginVertical: COMMAND_CENTER_SECTION_GAP,
backgroundColor: theme.colors.border,
},
row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] },
tallRow: { height: 56 },
row: { paddingHorizontal: theme.spacing[4], justifyContent: "center" },
activeRow: { backgroundColor: theme.colors.surface1 },
rowContent: {
flexDirection: "row",
@@ -933,14 +1030,31 @@ const styles = StyleSheet.create((theme) => ({
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 18,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.title,
flexShrink: 1,
},
fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.base, lineHeight: 20 },
fileLine: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.base,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine,
},
fileName: { color: theme.colors.foreground },
filePath: { color: theme.colors.foregroundMuted },
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: 16 },
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.subtitle,
},
subtitleLine: { flexDirection: "row", minWidth: 0 },
subtitleText: { flexShrink: 1, minWidth: 0 },
iconSlot: {
width: 16,
height: COMMAND_CENTER_LINE_HEIGHT.singleLine,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: { fontSize: 9 },
rowShortcut: { flexShrink: 0 },
breadcrumb: {
flex: 1,
@@ -958,7 +1072,7 @@ const styles = StyleSheet.create((theme) => ({
breadcrumbGroup: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 20,
lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine,
flexShrink: 0,
},
emptyText: {
@@ -978,7 +1092,4 @@ const styles = StyleSheet.create((theme) => ({
},
sheetBackground: { backgroundColor: theme.colors.surface0 },
sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
titledSection: { height: 32, justifyContent: "flex-end" },
dividedSection: { height: 49, justifyContent: "flex-end" },
dividerSection: { height: 17, justifyContent: "flex-end" },
}));
@@ -0,0 +1,53 @@
import { SPACING } from "@/styles/theme";
/**
* Every vertical size in the Command Center list, in one place.
*
* The list is virtualized, so it needs each row's height before rendering it. Rather than keep
* those numbers in sync with the styles by hand, rows render at exactly the height computed here
* and center their content. The styles read the line heights below, so this arithmetic is the
* layout.
*
* A layout chooses one thing: the inset above and below a row's text. Both layouts share the same
* shape, so nothing downstream asks which one it got.
*/
export const COMMAND_CENTER_LINE_HEIGHT = {
/** A row with no subtitle. Also the icon slot, so the icon centers on the title. */
singleLine: 20,
title: 18,
subtitle: 16,
sectionLabel: 16,
} as const;
/** Space around a section's label and around the rule between sections. */
export const COMMAND_CENTER_SECTION_GAP = SPACING[2];
const SECTION_DIVIDER_THICKNESS = 1;
export interface CommandCenterListMetrics {
singleLineRow: number;
twoLineRow: number;
/** A section label and the gaps around it. */
sectionTitle: number;
/** The rule between two sections and the gaps around it. */
sectionDivider: number;
}
function listMetrics(rowInset: number): CommandCenterListMetrics {
const line = COMMAND_CENTER_LINE_HEIGHT;
return {
singleLineRow: rowInset + line.singleLine + rowInset,
twoLineRow: rowInset + line.title + line.subtitle + rowInset,
sectionTitle: COMMAND_CENTER_SECTION_GAP + line.sectionLabel + COMMAND_CENTER_SECTION_GAP,
sectionDivider:
COMMAND_CENTER_SECTION_GAP + SECTION_DIVIDER_THICKNESS + COMMAND_CENTER_SECTION_GAP,
};
}
/**
* Desktop is dense for a pointer. Compact keeps single-line rows at the 44pt touch height that
* `density.rowHeight` uses on compact layouts.
*/
export const COMMAND_CENTER_LIST_METRICS = {
desktop: listMetrics(SPACING[1.5]),
compact: listMetrics(SPACING[3]),
} as const satisfies Record<string, CommandCenterListMetrics>;
+103 -54
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { CommandCenterContribution } from "./contributions";
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
import {
buildContributionSections,
filterAndRankBuiltInResults,
@@ -101,17 +102,28 @@ function autoModeChoice(): CommandCenterContribution {
});
}
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
function workspace(
id: string,
title = id,
subtitle = "host",
activityAt: Date | null = null,
): CommandCenterWorkspaceResult {
return {
kind: "workspace",
id,
title,
subtitle,
project: { viewKey: "project", name: "project", iconDataUri: null },
changeRequestNumber: null,
activityAt,
run: () => undefined,
};
}
function minutesAgo(minutes: number): Date {
return new Date(Date.UTC(2026, 9, 18, 12, 0) - minutes * 60_000);
}
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
const ids: string[] = [];
for (const section of sections) {
@@ -120,6 +132,8 @@ function sectionResultIds(sections: ReturnType<typeof buildContributionSections>
return ids;
}
const metrics = COMMAND_CENTER_LIST_METRICS.desktop;
describe("Command Center result projection", () => {
it("query-gates model choices and creates one flat row index", () => {
const contributions = [
@@ -130,16 +144,19 @@ describe("Command Center result projection", () => {
expect(sectionResultIds(emptySections)).toEqual(["settings"]);
const sections = buildContributionSections(contributions, "o");
const projection = projectCommandCenterRows([
...sections,
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("workspace:1")],
},
]);
const projection = projectCommandCenterRows(
[
...sections,
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("workspace:1")],
},
],
metrics,
);
expect(projection.rows.map((row) => row.key)).toEqual([
"section:models",
"opus",
@@ -147,7 +164,12 @@ describe("Command Center result projection", () => {
"workspace:1",
]);
expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3);
expect(projection.offsets).toEqual([0, 32, 68, 117]);
expect(projection.offsets).toEqual([
0,
metrics.sectionTitle,
metrics.sectionTitle + metrics.singleLineRow,
metrics.sectionTitle + metrics.singleLineRow + metrics.sectionDivider + metrics.sectionTitle,
]);
});
it("shows query-only actions only when their group matches a search", () => {
@@ -177,15 +199,18 @@ describe("Command Center result projection", () => {
it("keeps keyboard selection aligned with rows beyond the render window", () => {
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
const projection = projectCommandCenterRows([
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 0,
title: "Workspaces",
results: workspaces,
},
]);
const projection = projectCommandCenterRows(
[
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 0,
title: "Workspaces",
results: workspaces,
},
],
metrics,
);
let activeId: string | null = workspaces[0].id;
for (let index = 0; index < 150; index += 1) {
@@ -195,7 +220,7 @@ describe("Command Center result projection", () => {
const targetId = workspaces[150].id;
expect(activeId).toBe(targetId);
expect(projection.rowIndexByResultId.get(targetId)).toBe(151);
expect(projection.offsets[151]).toBe(32 + 150 * 56);
expect(projection.offsets[151]).toBe(metrics.sectionTitle + 150 * metrics.twoLineRow);
});
});
@@ -248,7 +273,7 @@ describe("Command Center relevance ordering", () => {
schedulesAction(),
action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }),
];
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""));
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""), metrics);
// Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature.
expect(projection.selectableResults.map((result) => result.id)).toEqual([
@@ -301,16 +326,19 @@ describe("Command Center relevance ordering", () => {
});
it("keeps Workspaces below contributions even on a perfect match", () => {
const projection = projectCommandCenterRows([
...buildContributionSections([schedulesAction()], "auto"),
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("auto")],
},
]);
const projection = projectCommandCenterRows(
[
...buildContributionSections([schedulesAction()], "auto"),
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("auto")],
},
],
metrics,
);
expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]);
});
@@ -324,7 +352,7 @@ describe("Command Center relevance ordering", () => {
title: "Workspaces",
results: [workspace("auto")],
};
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]);
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()], metrics);
expect(projection.selectableResults.map((result) => result.id)).toEqual([
"mode:auto",
@@ -461,15 +489,19 @@ describe("filterAndRankBuiltInResults", () => {
visible: [row.title, row.subtitle],
hidden: [],
});
const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) =>
left.title.localeCompare(right.title);
it("caps an empty query and preserves the caller's order", () => {
const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`));
it("shows the five most recent rows for an empty query", () => {
const rows = Array.from({ length: 8 }, (_, index) =>
workspace(`w${index}`, `w${index}`, "host", minutesAgo(8 - index)),
);
expect(
filterAndRankBuiltInResults(rows, "", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["w0", "w1", "w2", "w3", "w4"]);
expect(filterAndRankBuiltInResults(rows, "", visibleOnly).map((row) => row.id)).toEqual([
"w7",
"w6",
"w5",
"w4",
"w3",
]);
});
it("puts the best match first even when it sorts last alphabetically", () => {
@@ -480,9 +512,9 @@ describe("filterAndRankBuiltInResults", () => {
];
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
expect(
filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["c"]);
expect(filterAndRankBuiltInResults(rows, "lab des", visibleOnly).map((row) => row.id)).toEqual([
"c",
]);
});
it("ranks a title match above a match that only the hidden field reached", () => {
@@ -492,24 +524,41 @@ describe("filterAndRankBuiltInResults", () => {
? { visible: [row.title], hidden: ["deploy"] }
: { visible: [row.title], hidden: [] };
expect(
filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id),
).toEqual(["title-hit", "hidden-hit"]);
expect(filterAndRankBuiltInResults(rows, "deploy", fields).map((row) => row.id)).toEqual([
"title-hit",
"hidden-hit",
]);
});
it("breaks an equal score with the caller's tiebreak, not with match order", () => {
// Both titles are an exact prefix match for "task", so only the tiebreak separates them.
const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")];
it("orders rows in the same match tier by recency, not by where the word sits", () => {
// Both are whole-word matches for "usage"; the older one finds it earlier in the title.
const rows = [
workspace("older", "Deliver sidebar usage", "host", minutesAgo(60)),
workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)),
];
expect(
filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["first", "second"]);
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
"newer",
"older",
]);
});
it("keeps a better match tier above a more recent weaker match", () => {
const rows = [
workspace("subsequence", "Fix stuck toast", "fix/updating-messages", minutesAgo(1)),
workspace("whole-word", "Usage hover card", "host", minutesAgo(600)),
];
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
"whole-word",
"subsequence",
]);
});
it("drops a row when one token matches nothing", () => {
const rows = [workspace("a", "Label as Design")];
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly, byTitle)).toEqual([]);
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]);
});
});
+47 -19
View File
@@ -5,6 +5,7 @@ import {
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
import type { CommandCenterContribution } from "./contributions";
import type { CommandCenterListMetrics } from "./list-metrics";
/** Sections that reorder by how well they match the query. */
export const CONTRIBUTION_SECTION_BAND = 0;
@@ -43,13 +44,21 @@ export function compareCommandCenterScores(
return left.fieldRank - right.fieldRank || compareMatchScores(left.match, right.match);
}
export interface CommandCenterWorkspaceProject {
viewKey: string;
name: string;
iconDataUri: string | null;
}
export interface CommandCenterWorkspaceResult {
kind: "workspace";
id: string;
title: string;
subtitle: string;
project: CommandCenterWorkspaceProject;
/** PR/MR number backing this workspace, so a number query can match it exactly. */
changeRequestNumber: number | null;
activityAt: Date | null;
run(): void;
}
@@ -59,6 +68,7 @@ export interface CommandCenterAgentResult {
agent: AggregatedAgent;
title: string;
subtitle: string;
activityAt: Date | null;
run(): void;
}
@@ -169,13 +179,12 @@ function contributionSearchFields(
return { visible, hidden: contribution.keywords };
}
function resultHeight(result: CommandCenterResult): number {
if (result.kind === "workspace" || result.kind === "agent") return 56;
if (result.kind === "file") return 36;
if (result.contribution.presentation.kind === "action") {
return result.contribution.presentation.subtitle ? 56 : 36;
}
return 36;
/** Whether the row draws a second, subtitle line under its title. */
function hasSubtitleLine(result: CommandCenterResult): boolean {
if (result.kind === "workspace" || result.kind === "agent") return true;
if (result.kind === "file") return false;
const presentation = result.contribution.presentation;
return presentation.kind === "action" && Boolean(presentation.subtitle);
}
/**
@@ -203,22 +212,42 @@ function resultScore(result: CommandCenterResult): CommandCenterScore | undefine
/** Rows shown when nothing has been typed, per pinned category. */
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
/** A built-in row (workspace or agent) that orders by how recently it saw activity. */
export interface CommandCenterRecentRow {
activityAt: Date | null;
}
/** Most recent first. A row with no timestamp sorts after every row that has one. */
export function compareByRecency(left: CommandCenterRecentRow, right: CommandCenterRecentRow) {
return (right.activityAt?.getTime() ?? 0) - (left.activityAt?.getTime() ?? 0);
}
/**
* Rank built-in rows by the kind of match they made: which fields matched, then the matcher's
* tier. Offset and spread are deliberately left out. They only say where in the title the word
* happened to sit, so "usage" in "Deliver sidebar usage" and in "expose usage data" would be
* ordered by sentence position. Leaving them out makes those rows equal, and recency decides.
*/
export function compareBuiltInScores(left: CommandCenterScore, right: CommandCenterScore): number {
return left.fieldRank - right.fieldRank || left.match.tier - right.match.tier;
}
/**
* Filter and rank already-built rows. Rows in, rows out — building them needs translations,
* navigation callbacks and the project tree, none of which matching cares about, so that stays
* with the caller and this stays testable without mounting React.
*
* `tiebreak` is the caller's own ordering (alphabetical for workspaces, status and recency for
* agents). It decides equal-scoring rows, so an agent awaiting input still outranks an idle one
* that matched exactly as well.
* Rows rank by `compareBuiltInScores`; rows that rank equally, including every row of an empty
* query, go most recent first.
*/
export function filterAndRankBuiltInResults<Row>(
export function filterAndRankBuiltInResults<Row extends CommandCenterRecentRow>(
rows: readonly Row[],
query: string,
fieldsOf: (row: Row) => CommandCenterSearchFields,
tiebreak: (left: Row, right: Row) => number,
): Row[] {
if (!query.trim()) return rows.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
if (!query.trim()) {
return [...rows].sort(compareByRecency).slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
}
const scored: { row: Row; score: CommandCenterScore }[] = [];
for (const row of rows) {
@@ -227,7 +256,7 @@ export function filterAndRankBuiltInResults<Row>(
}
scored.sort(
(left, right) =>
compareCommandCenterScores(left.score, right.score) || tiebreak(left.row, right.row),
compareBuiltInScores(left.score, right.score) || compareByRecency(left.row, right.row),
);
return scored.map((entry) => entry.row);
}
@@ -293,6 +322,7 @@ export function buildContributionSections(
export function projectCommandCenterRows(
sections: readonly CommandCenterResultSection[],
metrics: CommandCenterListMetrics,
): CommandCenterListProjection {
const populated = sections
.filter((section) => section.results.length > 0)
@@ -305,10 +335,8 @@ export function projectCommandCenterRows(
for (const [sectionIndex, section] of populated.entries()) {
const divider = sectionIndex > 0;
let sectionHeight = 0;
if (section.title && divider) sectionHeight = 49;
if (section.title && !divider) sectionHeight = 32;
if (!section.title && divider) sectionHeight = 17;
const sectionHeight =
(divider ? metrics.sectionDivider : 0) + (section.title ? metrics.sectionTitle : 0);
if (sectionHeight > 0) {
offsets.push(offset);
rows.push({
@@ -321,7 +349,7 @@ export function projectCommandCenterRows(
offset += sectionHeight;
}
for (const result of section.results) {
const height = resultHeight(result);
const height = hasSubtitleLine(result) ? metrics.twoLineRow : metrics.singleLineRow;
offsets.push(offset);
rowIndexByResultId.set(result.id, rows.length);
rows.push({ kind: "result", key: result.id, result, height });
@@ -1,4 +1,4 @@
import type { GitAction, GitActions } from "@/git/policy";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
@@ -581,9 +581,7 @@ export function buildWorkspaceCommandCenterContributions(
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) {
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
@@ -7,21 +7,22 @@ function workspace(input: {
title?: string;
subtitle?: string;
changeRequestNumber?: number | null;
activityAt?: Date | null;
}): CommandCenterWorkspaceResult {
return {
kind: "workspace",
id: input.id,
title: input.title ?? input.id,
subtitle: input.subtitle ?? "",
project: { viewKey: "project", name: "project", iconDataUri: null },
changeRequestNumber: input.changeRequestNumber ?? null,
activityAt: input.activityAt ?? null,
run: () => undefined,
};
}
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
return filterAndRankWorkspaces(rows, query, (left, right) => left.id.localeCompare(right.id)).map(
(row) => row.id,
);
return filterAndRankWorkspaces(rows, query).map((row) => row.id);
}
describe("filterAndRankWorkspaces", () => {
@@ -79,4 +80,18 @@ describe("filterAndRankWorkspaces", () => {
const text = workspace({ id: "a-text", title: "Migration 42" });
expect(ids([text, subject], "42")).toEqual(["payments", "a-text"]);
});
it("orders workspaces in the same match tier by recency", () => {
const older = workspace({
id: "older",
title: "Usage hover card",
activityAt: new Date("2026-10-01T00:00:00Z"),
});
const newer = workspace({
id: "newer",
title: "Deliver sidebar usage",
activityAt: new Date("2026-10-17T00:00:00Z"),
});
expect(ids([older, newer], "usage")).toEqual(["newer", "older"]);
});
});
@@ -1,5 +1,6 @@
import {
compareCommandCenterScores,
compareBuiltInScores,
compareByRecency,
filterAndRankBuiltInResults,
scoreSearchFields,
type CommandCenterScore,
@@ -33,29 +34,22 @@ function searchFields(workspace: CommandCenterWorkspaceResult) {
return { visible: [workspace.title, workspace.subtitle], hidden: [] };
}
function compareScoredWorkspaces(
left: ScoredWorkspace,
right: ScoredWorkspace,
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
): number {
function compareScoredWorkspaces(left: ScoredWorkspace, right: ScoredWorkspace): number {
if (left.changeRequestHit !== right.changeRequestHit) {
return left.changeRequestHit ? -1 : 1;
}
if (left.score && right.score) {
const scoreDelta = compareCommandCenterScores(left.score, right.score);
const scoreDelta = compareBuiltInScores(left.score, right.score);
if (scoreDelta !== 0) return scoreDelta;
}
return tiebreak(left.workspace, right.workspace);
return compareByRecency(left.workspace, right.workspace);
}
export function filterAndRankWorkspaces(
workspaces: readonly CommandCenterWorkspaceResult[],
query: string,
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
): CommandCenterWorkspaceResult[] {
if (!query.trim()) {
return filterAndRankBuiltInResults(workspaces, query, searchFields, tiebreak);
}
if (!query.trim()) return filterAndRankBuiltInResults(workspaces, query, searchFields);
const changeRequestNumber = parseChangeRequestQuery(query);
const matches: ScoredWorkspace[] = [];
@@ -65,6 +59,6 @@ export function filterAndRankWorkspaces(
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit });
}
matches.sort((left, right) => compareScoredWorkspaces(left, right, tiebreak));
matches.sort(compareScoredWorkspaces);
return matches.map((match) => match.workspace);
}
+7 -4
View File
@@ -48,6 +48,8 @@ const DATE_SECTION_ORDER = [
"older",
] as const satisfies readonly DateSectionKey[];
const HISTORY_MATCH_OPTIONS = { typoTolerant: true } as const;
type FlatListItem =
| { type: "header"; key: string; section: DateSectionKey }
| { type: "agent"; key: string; agent: AggregatedAgent };
@@ -190,10 +192,11 @@ function SessionRow({
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const ranges = useMemo(
() => ({
workspace: findHighlightRanges(search ?? "", workspaceName),
title: findHighlightRanges(search ?? "", agent.title ?? ""),
branch: findHighlightRanges(search ?? "", branch),
project: findHighlightRanges(search ?? "", projectName),
// History search matches typo-tolerantly on the daemon, so the marks do too.
workspace: findHighlightRanges(search ?? "", workspaceName, HISTORY_MATCH_OPTIONS),
title: findHighlightRanges(search ?? "", agent.title ?? "", HISTORY_MATCH_OPTIONS),
branch: findHighlightRanges(search ?? "", branch, HISTORY_MATCH_OPTIONS),
project: findHighlightRanges(search ?? "", projectName, HISTORY_MATCH_OPTIONS),
}),
[search, workspaceName, agent.title, branch, projectName],
);
@@ -0,0 +1,17 @@
import Svg, { Path } from "react-native-svg";
interface AntigravityIconProps {
size?: number;
color?: string;
}
export function AntigravityIcon({ size = 16, color = "currentColor" }: AntigravityIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path
transform="translate(12 12) scale(0.24) translate(-55.738 -56.945)"
d="M89.6992 93.695C94.3659 97.195 101.366 94.8617 94.9492 88.445C75.6992 69.7783 79.7825 18.445 55.8659 18.445C31.9492 18.445 36.0325 69.7783 16.7825 88.445C9.78251 95.445 17.3658 97.195 22.0325 93.695C40.1159 81.445 38.9492 59.8617 55.8659 59.8617C72.7825 59.8617 71.6159 81.445 89.6992 93.695Z"
/>
</Svg>
);
}
@@ -0,0 +1,18 @@
import Svg, { Path } from "react-native-svg";
interface KiroIconProps {
size?: number;
color?: string;
}
export function KiroIcon({ size = 16, color = "currentColor" }: KiroIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001zM12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z"
/>
</Svg>
);
}
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
import { isWeb } from "@/constants/platform";
import { density } from "@/styles/density";
/** Stacks Markdown blocks rendered as separate views with the same gap the text puts between its blocks. */
export function MarkdownBlockStack({
imageSources,
children,
}: {
imageSources?: Record<string, string>;
children: ReactNode;
}) {
return (
<View
style={styles.stack}
dataSet={isWeb && imageSources ? markdownCopyImageSourcesDataSet(imageSources) : undefined}
>
{children}
</View>
);
}
const styles = StyleSheet.create({
stack: { gap: density.blockGap },
});
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
import { Pressable, View, type ViewStyle } from "react-native";
import { Code, Maximize2, Workflow } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -68,15 +68,6 @@ function MermaidFenceHostImpl({
],
[openFullscreen, showSourcePress, t],
);
const sourceView = useMemo(() => {
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
const margins: ViewStyle = {
marginTop: marginTop ?? marginVertical,
marginBottom: marginBottom ?? marginVertical,
};
const text: TextStyle = sourceTextStyle;
return { container: [margins, sourceContainerStyle], text };
}, [textStyle]);
const diagramStyle = useMemo(
() => [
getDiagramBoxStyle(textStyle),
@@ -90,19 +81,17 @@ function MermaidFenceHostImpl({
[visible],
);
const sourceVisible = !diagramVisible;
const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
const sourceTextStyle = showSource ? sourceView.text : textStyle;
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
return (
<>
{sourceVisible ? (
<View style={sourceContainer}>
<View style={sourceContainerStyle}>
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={sourceTextStyle}
textStyle={textStyle}
/>
{showSource && canShowDiagram ? (
<Pressable
@@ -6,9 +6,6 @@ export function getDiagramBoxStyle(style: TextStyle): ViewStyle {
borderColor: style.borderColor,
borderRadius: style.borderRadius,
borderWidth: style.borderWidth,
marginBottom: style.marginBottom,
marginTop: style.marginTop,
marginVertical: style.marginVertical,
padding: style.padding,
};
}
@@ -1,18 +0,0 @@
import React, { type ReactElement } from "react";
import { View } from "react-native";
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
import { isWeb } from "@/constants/platform";
export function MarkdownRendererFrame({
imageSources,
children,
}: {
imageSources: Record<string, string>;
children: ReactElement;
}) {
return (
<View dataSet={isWeb ? markdownCopyImageSourcesDataSet(imageSources) : undefined}>
{children}
</View>
);
}
@@ -208,6 +208,56 @@ it("lets the engine own code boundaries while classifying candidate values", asy
]);
});
describe("bare URL sentence punctuation", () => {
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
"leaves %s outside the link and preserves the rendered sentence",
async (punctuation) => {
const url = "https://github.com/getpaseo/paseo/pull/7357";
const source = `${url}${punctuation} Not merged.`;
const prepared = prepareMarkdownSource(source);
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
expect(tree.children?.[0]?.children).toEqual([
{
type: "Link",
attributes: { url },
children: [{ type: "Text", content: url }],
},
{ type: "Text", content: `${punctuation} Not merged.` },
]);
},
);
it.each([
"https://example.com/a.b",
"https://example.com/a?foo=bar&baz=2",
"https://example.com/a#L10",
"https://en.wikipedia.org/wiki/Function_(mathematics)",
"https://example.com/a_(b_(c))",
])("preserves internal URL punctuation: %s", async (url) => {
const tree = await parseMarkdown(prepareMarkdownSource(`${url}.`).render());
expect(tree.children?.[0]?.children).toEqual([
{
type: "Link",
attributes: { url },
children: [{ type: "Text", content: url }],
},
{ type: "Text", content: "." },
]);
});
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
"preserves punctuation in explicit destinations: %s",
async (source) => {
const tree = await parseMarkdown(prepareMarkdownSource(source).render());
expect(tree.children?.[0]?.children?.[0]?.attributes?.url).toBe("https://example.com/a.");
},
);
});
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`";
const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode;
@@ -307,7 +357,7 @@ describe("shared Markdown theme colours", () => {
["dark", darkTheme],
["light", lightTheme],
] as const)("uses %s theme colours for tables and task labels", (_name, theme) => {
const { markdownStyle: style } = createMarkdownStyles(theme);
const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
expect(style.table).toMatchObject({
color: theme.colors.foreground,
headerTextColor: theme.colors.foreground,
@@ -337,7 +387,7 @@ it.each([
] as const)(
"uses %s theme colours for quotes, admonitions, code, links and dividers",
(_name, theme) => {
const { markdownStyle: style } = createMarkdownStyles(theme);
const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
expect(style.blockquote).toMatchObject({
color: `${theme.colors.foreground}cc`,
backgroundColor: theme.colors.surface1,
@@ -364,7 +414,7 @@ it.each([
);
it.each([darkTheme, lightTheme])("supplies the theme selection colour to the renderer", (theme) => {
expect(createMarkdownStyles(theme)).toHaveProperty(
expect(createMarkdownStyles(theme, { blockGap: 16 })).toHaveProperty(
"selectionColor",
theme.colors.terminal.selectionBackground,
);
@@ -431,7 +481,7 @@ it("preserves backslashes and decodes entities inside HTML code", () => {
it.each([false, true])(
"caps inline images to the paragraph line height (compact: %s)",
(compact) => {
const { markdownStyle } = createMarkdownStyles(darkTheme, compact);
const { markdownStyle } = createMarkdownStyles(darkTheme, { compact, blockGap: 16 });
expect(markdownStyle.inlineImage?.size).toBe(markdownStyle.paragraph?.lineHeight);
},
);
@@ -718,3 +768,10 @@ it("replaces the native placeholder only after the resolved URL decodes", () =>
const prepared = prepareMarkdownSource("![image](shot.png)", undefined, { supportsSvg: false });
expect(prepared.render(new Map([["shot.png", url]]))).toBe(`![image](<${url}>)`);
});
it("makes the web Markdown container selectable in Safari as well as unprefixed browsers", () => {
expect(createMarkdownStyles(lightTheme, { blockGap: 12 }).containerStyle).toEqual({
userSelect: "text",
WebkitUserSelect: "text",
});
});
@@ -9,7 +9,8 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
import { MarkdownRendererFrame } from "./frame";
import { density, resolveDensity } from "@/styles/density";
import { MarkdownBlockStack } from "./block-stack";
import { WEB_RENDERERS } from "./renderers";
import { createMarkdownStyles } from "./styles";
import { PENDING_MARKDOWN_IMAGE } from "./source";
@@ -21,9 +22,11 @@ import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fen
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
const regularStyle = (theme: Theme) => createMarkdownStyles(theme);
const compactStyle = (theme: Theme) => createMarkdownStyles(theme, true);
const WEB_CONTAINER = { userSelect: "text" as const };
// Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it.
const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) =>
createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) });
const regularStyle = markdownStyle(false);
const compactStyle = markdownStyle(true);
export interface MarkdownRendererProps extends MarkdownImageContext {
text: string;
@@ -43,21 +46,20 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
);
if (!streaming || boundary === 0) return <MarkdownPart {...props} />;
return (
<>
<MarkdownPart {...props} text={props.text.slice(0, boundary)} continues />
<MarkdownBlockStack>
<MarkdownPart {...props} text={props.text.slice(0, boundary)} />
<MarkdownPart {...props} text={props.text.slice(boundary)} />
</>
</MarkdownBlockStack>
);
});
const MarkdownPart = memo(function MarkdownPart({
text,
continues = false,
compact = false,
streaming = false,
onLinkPress,
...imageContext
}: MarkdownRendererProps & { continues?: boolean }) {
}: MarkdownRendererProps) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const client =
@@ -86,35 +88,34 @@ const MarkdownPart = memo(function MarkdownPart({
isWeb ? [{ kind: "markdown", id: "0", source: markdown }] : splitMermaidFences(markdown),
[markdown],
);
const content = (
return (
<MermaidStreamingContext.Provider value={streaming}>
{segments.map((segment) =>
segment.kind === "mermaid" ? (
<MermaidFence key={`${segment.kind}:${segment.id}`} code={segment.source} />
) : (
<ThemedMarkdown
key={`${segment.kind}:${segment.id}`}
markdown={segment.source}
containerStyle={isWeb ? WEB_CONTAINER : undefined}
uniProps={compact ? compactStyle : regularStyle}
flavor="github"
selectable
allowTrailingMargin={continues && segment === segments[segments.length - 1]}
md4cFlags={MD4C_FLAGS}
linkRecognition={linkRecognition}
enableTaskListItemToggle={false}
enableLinkPreview={false}
enableBlockContextMenu={false}
// JS reveal owns pacing; native fade animation is a separate experiment.
streamingAnimation={false}
onLinkPress={pressLink}
onImagePress={pressImage}
renderers={isWeb ? WEB_RENDERERS : undefined}
/>
),
)}
<MarkdownBlockStack imageSources={imageSources}>
{segments.map((segment) =>
segment.kind === "mermaid" ? (
<MermaidFence key={`${segment.kind}:${segment.id}`} code={segment.source} />
) : (
<ThemedMarkdown
key={`${segment.kind}:${segment.id}`}
markdown={segment.source}
uniProps={compact ? compactStyle : regularStyle}
flavor="github"
selectable
md4cFlags={MD4C_FLAGS}
linkRecognition={linkRecognition}
enableTaskListItemToggle={false}
enableLinkPreview={false}
enableBlockContextMenu={false}
// JS reveal owns pacing; native fade animation is a separate experiment.
streamingAnimation={false}
onLinkPress={pressLink}
onImagePress={pressImage}
renderers={isWeb ? WEB_RENDERERS : undefined}
/>
),
)}
</MarkdownBlockStack>
{image ? <AttachmentLightbox source={image} onClose={closeImage} /> : null}
</MermaidStreamingContext.Provider>
);
return <MarkdownRendererFrame imageSources={imageSources}>{content}</MarkdownRendererFrame>;
});
@@ -1,5 +1,5 @@
import { Text } from "react-native";
import { forwardRef, useMemo, type ComponentProps } from "react";
import { forwardRef, useCallback, useMemo, useState, type ComponentProps } from "react";
import type {
CodeBlockRendererProps,
LinkRendererProps,
@@ -27,14 +27,29 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
}
// The library styles links inline, so a stylesheet :hover rule cannot underline them.
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
const [hovered, setHovered] = useState(false);
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
const anchorStyle = useMemo(
() => ({ ...anchorProps.style, overflowWrap: "anywhere" as const }),
[anchorProps.style],
() => ({
...anchorProps.style,
overflowWrap: "anywhere" as const,
...(hovered ? LINK_HOVER_STYLE : null),
}),
[anchorProps.style, hovered],
);
const anchor = (
<a {...anchorProps} style={anchorStyle}>
<a
{...anchorProps}
style={anchorStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</a>
);
+11 -7
View File
@@ -2,27 +2,31 @@ import type { MarkdownStyle } from "react-native-enriched-markdown";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
// iOS Safari needs the prefix to override the gesture host's selection suppression.
const WEB_CONTAINER = { userSelect: "text", WebkitUserSelect: "text" } as const;
export function createMarkdownStyles(
theme: Theme,
compact = false,
): { markdownStyle: MarkdownStyle; selectionColor: string } {
{ compact = false, blockGap }: { compact?: boolean; blockGap: number },
): { markdownStyle: MarkdownStyle; selectionColor: string; containerStyle?: typeof WEB_CONTAINER } {
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
// Every block is followed by the same gap; the library drops it after the last block.
const block = { marginTop: 0, marginBottom: blockGap };
const paragraph = {
fontSize,
fontFamily: theme.fontFamily.ui,
lineHeight: Math.round(fontSize * 1.4),
color: theme.colors.foreground,
marginTop: 0,
marginBottom: theme.spacing[3],
...block,
};
const heading = (scale: number) => ({
...paragraph,
fontSize: Math.round(fontSize * scale),
lineHeight: Math.round(fontSize * scale * 1.3),
fontWeight: "bold",
marginTop: theme.spacing[3],
});
return {
containerStyle: isWeb ? WEB_CONTAINER : undefined,
selectionColor: theme.colors.terminal.selectionBackground,
markdownStyle: {
paragraph,
@@ -93,8 +97,8 @@ export function createMarkdownStyles(
borderColor: theme.colors.foregroundMuted,
},
inlineImage: { size: paragraph.lineHeight },
image: { height: 320, resizeMode: "contain", borderRadius: 8 },
thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 },
image: { ...block, height: 320, resizeMode: "contain", borderRadius: 8 },
thematicBreak: { ...block, color: theme.colors.border, height: 1 },
},
};
}
+8 -1
View File
@@ -26,6 +26,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
@@ -2208,6 +2209,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
interface ToolCallProps {
agentMessage?: AgentMessage;
serverId?: string;
toolName: string;
args?: unknown;
result?: unknown;
@@ -2228,6 +2230,7 @@ interface ToolCallProps {
export const ToolCall = memo(function ToolCall({
agentMessage,
serverId,
toolName,
args,
result,
@@ -2265,6 +2268,7 @@ export const ToolCall = memo(function ToolCall({
return undefined;
}, [detail, args, result]);
const targetAgentTitle = useTargetAgentTitle({ serverId, toolName, detail: effectiveDetail });
const presentation = useMemo(
() =>
buildToolCallPresentation({
@@ -2275,9 +2279,10 @@ export const ToolCall = memo(function ToolCall({
detail: effectiveDetail,
metadata,
cwd,
targetAgentTitle,
resolveIcon: resolveToolCallIcon,
}),
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle],
);
const handleOpenFile = useMemo(() => {
const openFilePath = presentation.openFilePath;
@@ -2393,6 +2398,8 @@ export const ToolCall = memo(function ToolCall({
function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.toolName !== next.toolName) return false;
if (previous.serverId !== next.serverId) return false;
if (previous.agentMessage !== next.agentMessage) return false;
if (previous.args !== next.args) return false;
if (previous.result !== next.result) return false;
if (previous.error !== next.error) return false;
@@ -17,8 +17,10 @@ describe("resolveProviderIconName", () => {
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
expect(resolveProviderIconName("devin")).toEqual({ kind: "catalog", id: "devin" });
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" });
expect(resolveProviderIconName("hermes")).toEqual({ kind: "catalog", id: "hermes" });
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
});
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { Bot } from "lucide-react-native";
import { Bot, PackagePlus } from "lucide-react-native";
import { SvgXml } from "react-native-svg";
import { afterEach, describe, expect, it } from "vitest";
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
@@ -17,6 +17,16 @@ function renderIcon(Component: ProviderIconComponent) {
}
describe("useProviderIcon", () => {
it.each(["devin", "hermes", "kiro", "antigravity"])(
"#7087: %s has a provider mark before a host snapshot arrives",
(provider) => {
const Icon = renderHook(() => useProviderIcon(provider)).result.current;
expect(Icon).not.toBe(Bot);
expect(Icon).not.toBe(PackagePlus);
expect(typeof Icon).toBe("function");
},
);
it("renders registered snapshot SVG metadata with the requested size and color", () => {
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
@@ -1,12 +1,14 @@
import { useStore } from "zustand";
import { providerSnapshotIcons } from "@/data/provider-icons";
import { Bot, PackagePlus } from "lucide-react-native";
import { Bot } from "lucide-react-native";
import { createElement, useCallback, type ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { TERMINAL_PROFILE_ICON_NAMES } from "@getpaseo/protocol/provider-icon-names";
import { AntigravityIcon } from "@/components/icons/antigravity-icon";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { KiroIcon } from "@/components/icons/kiro-icon";
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
@@ -23,10 +25,11 @@ export interface ProviderIconProps {
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
antigravity: AntigravityIcon as unknown as ProviderIconComponent,
claude: ClaudeIcon as unknown as ProviderIconComponent,
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
kiro: PackagePlus,
kiro: KiroIcon as unknown as ProviderIconComponent,
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
omp: OmpIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
@@ -41,7 +41,11 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
useHosts,
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import {
useSidebarWorkspacePinController,
@@ -1596,14 +1600,18 @@ function ProjectBlock({
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
project,
collapsed,
supportsMultiplicityByServerId,
preferredServerId,
}),
[collapsed, project, supportsMultiplicityByServerId],
[collapsed, preferredServerId, project, supportsMultiplicityByServerId],
);
// Collapsed rows hide their workspace rows, so the project row carries the most urgent
@@ -1848,6 +1856,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
areProjectBlockSelectionsEqual(previous, next)
);
}
@@ -0,0 +1,85 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAllByRole } from "@testing-library/dom";
import { i18n as testI18n } from "@/i18n/i18next";
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
import { SidebarProjectEmptyState } from "./empty-states";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
afterEach(async () => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
await testI18n.changeLanguage("en");
});
async function mountEmptyState(language: string, width: number): Promise<HTMLElement> {
await testI18n.changeLanguage(language);
const container = document.createElement("div");
container.style.width = `${width}px`;
// Match the workspace list's horizontal inset around the card.
container.style.paddingInline = "6px";
container.style.boxSizing = "border-box";
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<SidebarProjectEmptyState />));
mounted.push({ root, container });
const card = container.querySelector('[data-testid="sidebar-project-empty-state"]');
if (!(card instanceof HTMLElement)) {
throw new Error("SidebarProjectEmptyState did not render its card");
}
return card;
}
function expectActionsInsideCard(card: HTMLElement) {
const cardRect = card.getBoundingClientRect();
const buttons = getAllByRole(card, "button");
expect(buttons).toHaveLength(2);
for (const button of buttons) {
const rect = button.getBoundingClientRect();
expect(rect.left).toBeGreaterThanOrEqual(cardRect.left);
expect(rect.right).toBeLessThanOrEqual(cardRect.right);
// Containing the button must also keep the whole label readable, without clipping.
const label = document.createRange();
label.selectNodeContents(button);
for (const fragment of Array.from(label.getClientRects())) {
expect(fragment.left).toBeGreaterThanOrEqual(rect.left);
expect(fragment.right).toBeLessThanOrEqual(rect.right);
expect(fragment.top).toBeGreaterThanOrEqual(rect.top);
expect(fragment.bottom).toBeLessThanOrEqual(rect.bottom);
}
}
}
describe("SidebarProjectEmptyState", () => {
const cases = ["en", "ru", "fr", "ja", "es", "pt-BR", "ar", "ko", "zh-CN"].flatMap((language) =>
[MIN_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH].map((width) => ({ language, width })),
);
it.each(cases)(
"keeps every action inside the card ($language, $width px sidebar)",
async ({ language, width }) => {
expectActionsInsideCard(await mountEmptyState(language, width));
},
);
it("keeps English actions on one row when they fit", async () => {
const card = await mountEmptyState("en", DEFAULT_SIDEBAR_WIDTH);
const [addProject, importSession] = getAllByRole(card, "button");
expect(addProject.getBoundingClientRect().top).toBe(importSession.getBoundingClientRect().top);
});
});
@@ -54,7 +54,13 @@ export function SidebarFilterEmptyState() {
title={t("sidebar.filterEmpty.title")}
description={t("sidebar.filterEmpty.description")}
>
<Button variant="secondary" size="xs" onPress={clearFilters}>
<Button
variant="secondary"
size="xs"
style={styles.action}
textStyle={styles.actionText}
onPress={clearFilters}
>
{t("sidebar.filterEmpty.clear")}
</Button>
</SidebarEmptyStateCard>
@@ -76,10 +82,24 @@ export function SidebarProjectEmptyState({
title={t("sidebar.project.empty.title")}
description={t("sidebar.project.empty.description")}
>
<Button variant="secondary" size="xs" leftIcon={Plus} onPress={onAddProject}>
<Button
variant="secondary"
size="xs"
leftIcon={Plus}
style={styles.action}
textStyle={styles.actionText}
onPress={onAddProject}
>
{t("sidebar.actions.addProject")}
</Button>
<Button variant="outline" size="xs" leftIcon={Import} onPress={onImportSession}>
<Button
variant="outline"
size="xs"
leftIcon={Import}
style={styles.action}
textStyle={styles.actionText}
onPress={onImportSession}
>
{t("importSession.title")}
</Button>
</SidebarEmptyStateCard>
@@ -99,9 +119,16 @@ const styles = StyleSheet.create((theme) => ({
},
actions: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
action: {
maxWidth: "100%",
},
actionText: {
flexShrink: 1,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
@@ -1,5 +1,6 @@
import {
Fragment,
memo,
useCallback,
useEffect,
useMemo,
@@ -88,7 +89,8 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { RenderProfile } from "@/utils/render-profiler";
import { recordRenderProfileReasons, RenderProfile } from "@/utils/render-profiler";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { isNative } from "@/constants/platform";
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
@@ -435,7 +437,7 @@ export function SplitContainer({
: inheritedWindowChromeCorners;
const mainColumnStyle = styles.mainColumn;
const explorerSidebarDockStyle = useMemo(
() => [styles.explorerSidebarDock, { width: explorerSidebarWidth }],
() => [styles.explorerSidebarDock, inlineUnistylesStyle({ width: explorerSidebarWidth })],
[explorerSidebarWidth],
);
const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => {
@@ -1121,7 +1123,7 @@ function SplitNodeView({
);
}
function SplitPaneView({
const SplitPaneView = memo(function SplitPaneView({
pane,
uiTabs,
isFocused,
@@ -1158,6 +1160,7 @@ function SplitPaneView({
focusModeEnabled,
onExitFocusMode,
}: SplitPaneViewProps) {
recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]);
const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane);
const paneState = useMemo(
@@ -1312,7 +1315,7 @@ function SplitPaneView({
</View>
</RenderProfile>
);
}
});
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
const next = new Map<string, SplitPane>();
@@ -868,7 +868,9 @@ function NativeTerminalEmulator({
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
return;
}
@@ -915,7 +917,10 @@ function NativeTerminalEmulator({
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (selectionPress?.status === "selecting") {
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
@@ -14,7 +14,7 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import {
buildPaseoToolDetailSections,
type PaseoToolDetailSection,
} from "@getpaseo/protocol/paseo-tool-call-detail";
} from "@getpaseo/protocol/paseo-tool-presentation/index";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { highlightDiffLines } from "@/utils/diff-highlight";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
@@ -63,7 +63,9 @@ describe("findHighlightRanges", () => {
);
});
it("uses the search matcher for typo and scattered-character matches", () => {
expect(marked("billing", findHighlightRanges("bulling", "billing"))).toBe("[billing]");
expect(
marked("billing", findHighlightRanges("bulling", "billing", { typoTolerant: true })),
).toBe("[billing]");
expect(marked("paseo-babysit", findHighlightRanges("pasbab", "paseo-babysit"))).toBe(
"[pas]eo-[bab]ysit",
);
@@ -79,6 +81,9 @@ describe("findHighlightRanges", () => {
"check [conf]i[g]uration",
);
});
it("marks typos only when the list was matched typo-tolerantly", () => {
expect(findHighlightRanges("bulling", "billing")).toEqual([]);
});
it("merges overlapping tokens and ignores empty or unmatched queries", () => {
expect(marked("billing", findHighlightRanges("bill billing", "billing"))).toBe("[billing]");
expect(findHighlightRanges("", "billing")).toEqual([]);
@@ -4,6 +4,7 @@ import {
scoreMatch,
tokenizeQuery,
type MatchRange,
type TextFieldsOptions,
} from "@getpaseo/protocol/search/text-match";
export interface HighlightSegment {
@@ -40,11 +41,22 @@ export function toHighlightSegments(
return segments;
}
/** Best-effort presentation of each query token in this field, independently of other fields. */
export function findHighlightRanges(query: string, text: string): MatchRange[] {
/**
* Best-effort presentation of each query token in this field, independently of other fields.
* Pass the same options the list was matched with, so a mark never claims a match the list did
* not make.
*/
export function findHighlightRanges(
query: string,
text: string,
options: TextFieldsOptions = {},
): MatchRange[] {
const ranges = tokenizeQuery(query)
.flatMap((token) => {
const score = scoreMatch(token, text, { fuzzy: fuzzyPolicyForToken(token) });
const score = scoreMatch(token, text, {
fuzzy: options.typoTolerant ? fuzzyPolicyForToken(token) : null,
subsequence: options.subsequence,
});
return score ? matchRanges(token, text, score) : [];
})
.sort((left, right) => left.start - right.start);
@@ -24,7 +24,7 @@ export function validateDraftSubmission(input: {
};
autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null;
workspaceDirectory: string | null;
hasClient: boolean;
isConnected: boolean;
}): string | null {
const {
text,
@@ -32,7 +32,7 @@ export function validateDraftSubmission(input: {
composerState,
autoSubmitConfig,
workspaceDirectory,
hasClient,
isConnected,
} = input;
const readiness = resolveSubmissionReadiness({
text,
@@ -46,7 +46,7 @@ export function validateDraftSubmission(input: {
},
autoSubmitConfig,
workspaceDirectory,
hasClient,
isConnected,
});
return readiness.ok ? null : (readiness.reason ?? null);
}
@@ -17,12 +17,26 @@ function validate(overrides = {}) {
composerState: baseComposerState,
autoSubmitConfig: null,
workspaceDirectory: "/tmp/project",
hasClient: true,
isConnected: true,
...overrides,
});
}
describe("workspace draft agent model validation", () => {
test("reports an offline host before an absent catalog or pending model defaults", () => {
expect(
validate({
isConnected: false,
composerState: {
...baseComposerState,
providerDefinitions: [],
selectedProvider: null,
isModelLoading: true,
},
}),
).toBe("Host is not connected");
});
test("allows a ready provider with no models to submit without a selected model", () => {
expect(validate({})).toBeNull();
});
@@ -16,7 +16,7 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -305,6 +305,7 @@ export function WorkspaceDraftAgentTab({
}: WorkspaceDraftAgentTabProps) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
workspaceDirectory: w.workspaceDirectory,
id: w.id,
@@ -445,7 +446,7 @@ export function WorkspaceDraftAgentTab({
composerState,
autoSubmitConfig,
workspaceDirectory: draftWorkingDirectory,
hasClient: Boolean(client),
isConnected,
});
},
onBeforeSubmit: async () => {
+6 -1
View File
@@ -1295,7 +1295,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// Read pointer capability when restoring, without making a pointer change itself
// a focus trigger.
useLayoutEffect(() => {
if (isPaneFocused && focusIntentRef.current && hasFinePointer()) {
const finePointer = hasFinePointer();
if (!isPaneFocused && isWeb && !finePointer) {
// Hiding a retained tab only blurs its editor after the browser renders.
// A quick return before that frame would otherwise keep touch focus.
textInputRef.current?.blur();
} else if (isPaneFocused && focusIntentRef.current && finePointer) {
textInputRef.current?.focus();
}
}, [isPaneFocused]);
@@ -4,6 +4,7 @@ import {
beginMessageSubmission,
getActiveMessageSubmissions,
getSendingClientMessageIds,
hasActiveMessageSubmission,
observeMessageSubmissionCanonical,
rejectMessageSubmission,
} from "./model";
@@ -53,6 +54,21 @@ describe("message submission transactions", () => {
expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]);
});
it("reports activity until every submission is provider acknowledged", () => {
const both = beginMessageSubmission(
beginMessageSubmission([], { clientMessageId: "client-1" }),
{ clientMessageId: "client-2" },
);
const oneAcknowledged = observeMessageSubmissionCanonical(both, ["client-1"]);
expect(hasActiveMessageSubmission(undefined)).toBe(false);
expect(hasActiveMessageSubmission(both)).toBe(true);
expect(hasActiveMessageSubmission(oneAcknowledged)).toBe(true);
expect(
hasActiveMessageSubmission(observeMessageSubmissionCanonical(oneAcknowledged, ["client-2"])),
).toBe(false);
});
it("records provider acknowledgement without settling another transaction", () => {
const both = beginMessageSubmission(
beginMessageSubmission([], { clientMessageId: "client-1" }),
+19 -23
View File
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState, type ReactElement, type ReactNode } fro
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
MENU_ITEM_HEIGHT,
MenuRoot,
MenuSeparator,
MenuSurface,
@@ -312,6 +313,22 @@ const styles = StyleSheet.create((theme) => {
backgroundColor: getStatusDotColor({ theme, bucket, showDoneAsInactive: true }) ?? undefined,
});
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
//
// Compact and desktop differ here and only here. A finger needs the taller row a menu row gets
// on compact; a pointer does not, and the popover keeps its density.
const trackRow = {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 },
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] },
borderRadius: theme.borderRadius.md,
} as const;
return {
bar: {
position: "absolute",
@@ -332,29 +349,8 @@ const styles = StyleSheet.create((theme) => {
alignItems: "center",
gap: theme.spacing[1],
},
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 32,
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
rowActive: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 32,
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
row: trackRow,
rowActive: { ...trackRow, backgroundColor: theme.colors.surface2 },
// Segments sit twice as far apart as a mark sits from its own text, so a count reads as
// belonging to the mark on its left rather than to the words on its right.
segments: {
@@ -133,7 +133,7 @@ const CATALOG_DATA = [
title: "Devin CLI",
description: "Cognition's Devin for Terminal via Agent Client Protocol",
version: "manual",
iconId: null,
iconId: "devin",
installLink: "https://cli.devin.ai/docs",
command: ["devin", "acp"],
},
@@ -235,7 +235,7 @@ const CATALOG_DATA = [
title: "Hermes",
description: "Nous Research self-improving AI agent",
version: "manual",
iconId: null,
iconId: "hermes",
installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
command: ["hermes", "acp"],
},
@@ -272,7 +272,7 @@ const CATALOG_DATA = [
title: "Kiro CLI",
description: "Amazon's AI coding agent with native ACP support",
version: "manual",
iconId: null,
iconId: "kiro",
installLink: "https://kiro.dev/docs/cli/acp/",
command: ["kiro-cli", "acp"],
},
@@ -15,7 +15,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { GitAction, GitActions } from "@/git/policy";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
@@ -129,14 +129,10 @@ export function GitActionsSplitButton({
[theme.colors.surface2],
);
const menuOnlyActions = useMemo(
() => [
...(gitActions.primary ? [gitActions.primary] : []),
...gitActions.secondary,
...gitActions.menu,
],
[gitActions.menu, gitActions.primary, gitActions.secondary],
);
const menuOnlyActions = [
...(gitActions.primary ? [gitActions.primary] : []),
...actionsWithoutPrimary(gitActions),
];
if (menuOnly) {
if (menuOnlyActions.length === 0) {
+54 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
@@ -190,6 +190,59 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
});
it("drops a promoted remote action from the remaining menu actions", () => {
const pull = buildGitActions(createInput({ hasRemote: true, behindOfOrigin: 2 }));
expect(pull.primary?.id).toBe("pull");
expect(actionsWithoutPrimary(pull).map((action) => action.id)).toEqual([
"push",
"pull-and-push",
"archive-workspace",
]);
const push = buildGitActions(createInput({ hasRemote: true, aheadOfOrigin: 1 }));
expect(push.primary?.id).toBe("push");
expect(actionsWithoutPrimary(push).map((action) => action.id)).toEqual([
"pull",
"pull-and-push",
"archive-workspace",
]);
});
it("drops a promoted merge action from the remaining menu actions", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "merge-pr-merge", label: "Merge PR (merge)" });
expect(actionsWithoutPrimary(actions).map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"archive-workspace",
]);
});
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
+12
View File
@@ -312,6 +312,18 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
};
}
/**
* The actions a flattened list shows after the primary. `secondary` keeps the actions the policy
* can promote to `primary`, and the translator rebuilds those objects, so a flattening consumer
* has to drop the promoted one by id or it renders it twice.
*/
export function actionsWithoutPrimary(gitActions: GitActions): GitAction[] {
const primaryActionId = gitActions.primary?.id;
return [...gitActions.secondary, ...gitActions.menu].filter(
(action) => action.id !== primaryActionId,
);
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive) {
return "archive-workspace";
@@ -11,6 +11,13 @@ function findProvider(id: string) {
}
describe("ACP provider catalog", () => {
it.each(["devin", "hermes", "kiro"])(
"#7087: %s supplies its provider mark to the catalog picker",
(provider) => {
expect(findProvider(provider).iconSvg).toContain("<svg");
},
);
it("vendors provider entries with unique ids and concrete commands", () => {
const ids = new Set<string>();
@@ -36,6 +36,7 @@ import {
type FormState,
type ProviderModelsByProvider,
} from "@/provider-selection/resolve-agent-form";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { MaterializedAgentProfile } from "@/agent-profiles";
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
@@ -186,6 +187,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, { cwd: workingDir });
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(
() => buildProviderDefinitions(snapshotEntries),
@@ -220,8 +223,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
[snapshotEntries],
);
const snapshotModelSelectorProviders = useMemo(
() => buildSelectableProviderSelectorProviders(snapshotEntries),
[snapshotEntries],
() => buildSelectableProviderSelectorProviders(snapshotEntries, isConnected),
[snapshotEntries, isConnected],
);
const snapshotSelectedEntry = useMemo(
() =>
@@ -247,7 +250,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const isModelSelectionLoading =
resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading;
isConnected &&
(resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading);
const isAllModelsLoading = isModelSelectionLoading;
useEffect(() => {
@@ -1,7 +1,10 @@
import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { useShallow } from "zustand/shallow";
import { formatAgentModeLabel } from "@/agent-controls/labels";
import { formatTokenCount } from "@/components/context-window-meter.utils";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useSessionStore } from "@/stores/session-store";
export interface AgentFactsInput {
models: AgentModelDefinition[] | null;
@@ -33,6 +36,32 @@ export function buildAgentFactsLine(input: AgentFactsInput): string | null {
return parts.join(" · ");
}
/** The facts line for a Paseo agent in the session store, kept current as the agent reports. */
export function useAgentFactsLine(serverId: string, agentId: string): string | null {
const agent = useSessionStore(
useShallow((state) => {
const current = state.sessions[serverId]?.agents.get(agentId);
return current
? {
provider: current.provider,
cwd: current.cwd,
runtimeModelId: current.runtimeInfo?.model ?? null,
configuredModelId: current.model ?? null,
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: current.thinkingOptionId ?? null,
availableModes: current.availableModes,
currentModeId: current.currentModeId,
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
}
: null;
}),
);
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
return agent ? buildAgentFactsLine({ ...agent, models }) : null;
}
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) {
const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0];
+2 -24
View File
@@ -60,9 +60,8 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
import { TimelineSyncStatus } from "@/timeline/sync-status";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import { buildAgentFactsLine } from "@/panels/agent-facts-line";
import { useAgentFactsLine } from "@/panels/agent-facts-line";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { useHasPluginComposerPills } from "@/plugins";
@@ -1384,28 +1383,7 @@ function TimelineSyncErrorCallout({
* without its composer.
*/
function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
const agent = useSessionStore(
useShallow((state) => {
const current = state.sessions[serverId]?.agents.get(agentId);
return current
? {
provider: current.provider,
cwd: current.cwd,
runtimeModelId: current.runtimeInfo?.model ?? null,
configuredModelId: current.model ?? null,
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: current.thinkingOptionId ?? null,
availableModes: current.availableModes,
currentModeId: current.currentModeId,
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
}
: null;
}),
);
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
const text = agent ? buildAgentFactsLine({ ...agent, models }) : null;
const text = useAgentFactsLine(serverId, agentId);
return text ? <PaneSubtitle text={text} testID="agent-pane-subtitle" /> : null;
}
@@ -15,6 +15,21 @@ import {
} from "./provider-selection";
describe("combined model selector data", () => {
it("shows a disconnected error for unfinished catalogs while keeping known models", () => {
const providers = buildSelectableProviderSelectorProviders(
[
snapshotEntry({ provider: "waiting", status: "loading", models: [] }),
snapshotEntry({ provider: "codex", status: "ready", models: [codexModel] }),
],
false,
);
expect(providers[0].modelSelection).toEqual({
kind: "error",
message: "Host is not connected",
});
expect(providers[1].modelSelection.kind).toBe("models");
});
const codexModel: AgentModelDefinition = {
provider: "codex",
id: "gpt-5.4",
@@ -367,7 +382,7 @@ describe("combined model selector data", () => {
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
isConnected: true,
}),
).toEqual({
ok: false,
@@ -387,7 +402,7 @@ describe("combined model selector data", () => {
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
isConnected: true,
}),
).toEqual({ ok: true });
});
@@ -426,7 +441,7 @@ describe("combined model selector data", () => {
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
isConnected: true,
}),
).toEqual({ ok: false, reason: "初始 prompt 必填" });
} finally {
@@ -103,6 +103,7 @@ function buildModelSelection(
function buildEntryModelSelection(
entry: ProviderSnapshotEntry,
label: string,
isConnected: boolean,
): ProviderModelSelection {
if ((entry.models?.length ?? 0) > 0) {
return buildModelSelection(entry.provider, label, entry.models ?? null);
@@ -111,7 +112,9 @@ function buildEntryModelSelection(
return buildModelSelection(entry.provider, label, entry.models ?? null);
}
if (entry.status === "loading") {
return { kind: "loading" };
return isConnected
? { kind: "loading" }
: { kind: "error", message: i18n.t("providerSelection.readiness.hostDisconnected") };
}
return {
kind: "error",
@@ -142,6 +145,7 @@ export function buildProviderSelectorProviders(input: {
export function buildSelectableProviderSelectorProviders(
entries: ProviderSnapshotEntry[] | undefined,
isConnected = true,
): ProviderSelectorProvider[] {
return (entries ?? [])
.filter((entry) => entry.enabled)
@@ -150,7 +154,7 @@ export function buildSelectableProviderSelectorProviders(
return {
id: entry.provider,
label,
modelSelection: buildEntryModelSelection(entry, label),
modelSelection: buildEntryModelSelection(entry, label, isConnected),
};
});
}
@@ -345,11 +349,14 @@ export function resolveSubmissionReadiness(input: {
};
autoSubmitConfig: { provider: string; model: string | null } | null;
workspaceDirectory: string | null;
hasClient: boolean;
isConnected: boolean;
}): ProviderSelectionReadiness {
if (!input.allowsEmptyAutoSubmit && !input.text.trim()) {
return { ok: false, reason: i18n.t("providerSelection.readiness.initialPromptRequired") };
}
if (!input.isConnected) {
return { ok: false, reason: i18n.t("providerSelection.readiness.hostDisconnected") };
}
if (input.providerCount === 0) {
return { ok: false, reason: i18n.t("providerSelection.readiness.noProviders") };
}
@@ -366,8 +373,5 @@ export function resolveSubmissionReadiness(input: {
if (!input.workspaceDirectory) {
return { ok: false, reason: i18n.t("providerSelection.readiness.workspaceDirectoryNotFound") };
}
if (!input.hasClient) {
return { ok: false, reason: i18n.t("providerSelection.readiness.hostDisconnected") };
}
return { ok: true };
}
@@ -182,6 +182,7 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
status: "done",
currentBranch: "main",
changeRequestNumber: null,
statusEnteredAt: null,
...overrides,
};
}
@@ -162,7 +162,7 @@ import { useWorkspaceRecovery } from "@/workspace-recovery/use-workspace-recover
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
import {
buildWorkspaceTabSnapshot,
deriveWorkspaceAgentVisibility,
createWorkspaceAgentVisibilitySelector,
workspaceAgentVisibilityEqual,
} from "@/workspace-tabs/agent-visibility";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
@@ -1148,14 +1148,17 @@ function WorkspaceScreenContent({
),
});
const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore,
(state) =>
deriveWorkspaceAgentVisibility({
sessionAgents: state.sessions[normalizedServerId]?.agents,
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
const selectWorkspaceAgentVisibility = useMemo(
() =>
createWorkspaceAgentVisibilitySelector({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore,
selectWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
);
+8 -1
View File
@@ -26,15 +26,22 @@ export const density = {
tightRowHeight: byLayout(36, 28),
/** A square icon-only button that sits in a row or toolbar. */
iconButton: byLayout(44, 28),
/** The space between two blocks of rich text, such as Markdown paragraphs, code and images. */
blockGap: byLayout(12, 16),
};
/** Resolves a density value for a prop, which Unistyles cannot resolve on its own. */
export function resolveDensity<T>(rt: { breakpoint?: string }, value: { xs: T; md: T }): T {
return isCompactBreakpoint(rt.breakpoint) ? value.xs : value.md;
}
/** The width or height of a slot that holds an icon of `size`. */
export function densityIconBox(size: DensityIconSize) {
return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
}
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
return resolveDensity(rt, densityIconBox(size));
}
/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */
+163 -1
View File
@@ -1,6 +1,11 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest";
import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select";
import {
createSubagentsForParentSelector,
selectProviderSubagentsForParent,
selectSubagentsForParent,
} from "./select";
import { createWorkspaceAgentVisibilitySelector } from "@/workspace-tabs/agent-visibility";
import { useProviderSubagentStore } from "./provider-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -435,3 +440,160 @@ describe("selectSubagentsForParent", () => {
);
});
});
describe("createSubagentsForParentSelector", () => {
class IterationCountingMap<K, V> extends Map<K, V> {
iterations = 0;
override values() {
this.iterations += 1;
return super.values();
}
override entries() {
this.iterations += 1;
return super.entries();
}
override [Symbol.iterator]() {
this.iterations += 1;
return super[Symbol.iterator]();
}
}
const params = { serverId: SERVER_ID, parentAgentId: "parent" };
function streamTick(text: string): void {
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", {
head: [
{
kind: "assistant_message",
id: "message-1",
text,
timestamp: new Date("2026-03-08T10:05:00.000Z"),
},
],
});
}
it("returns the cached rows without walking agents when a stream tick leaves them unchanged", () => {
const agents = new IterationCountingMap<string, Agent>(
[
makeAgent({ id: "parent" }),
makeAgent({ id: "child", parentAgentId: "parent" }),
makeAgent({ id: "unrelated" }),
].map((agent) => [agent.id, agent]),
);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, agents);
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(useSessionStore.getState());
const iterationsAfterFirstSelect = agents.iterations;
for (let tick = 0; tick < 100; tick += 1) {
streamTick(`token ${tick}`);
expect(select(useSessionStore.getState())).toBe(before);
}
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
expect(agents.iterations).toBe(iterationsAfterFirstSelect);
expect(before.map((row) => row.id)).toEqual(["child"]);
});
it("recomputes when the agents map changes", () => {
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child-a", parentAgentId: "parent" })]);
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(useSessionStore.getState());
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
const child = makeAgent({
id: "child-b",
parentAgentId: "parent",
createdAt: new Date("2026-03-08T10:01:00.000Z"),
});
return new Map(current).set(child.id, child);
});
const after = select(useSessionStore.getState());
expect(after).not.toBe(before);
expect(after.map((row) => row.id)).toEqual(["child-a", "child-b"]);
});
it("keeps the previous rows when a changed agents map yields the same rows", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({ id: "child", parentAgentId: "parent" }),
makeAgent({ id: "unrelated" }),
]);
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(useSessionStore.getState());
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
return new Map(current).set("unrelated", makeAgent({ id: "unrelated", status: "running" }));
});
expect(select(useSessionStore.getState())).toBe(before);
});
it("applies the pending archive ids it was created with", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({ id: "child-a", parentAgentId: "parent" }),
makeAgent({ id: "child-b", parentAgentId: "parent" }),
]);
const rows = createSubagentsForParentSelector(
params,
new Set(["child-b"]),
)(useSessionStore.getState());
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
it("does no agent map walks for 100 stream commits with unchanged source maps", () => {
const agents = new IterationCountingMap<string, Agent>(
Array.from({ length: 200 }, (_, i) => {
const agent = makeAgent({
id: `agent-${i}`,
workspaceId: "workspace",
parentAgentId: i < 3 ? null : `agent-${i % 3}`,
});
return [agent.id, agent] as const;
}),
);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, agents);
const selectVisibility = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: "workspace",
});
const selectChildren = Array.from({ length: 3 }, (_, i) =>
createSubagentsForParentSelector(
{ serverId: SERVER_ID, parentAgentId: `agent-${i}` },
EMPTY_PENDING_ARCHIVE_IDS,
),
);
const select = (state: ReturnType<typeof useSessionStore.getState>) => {
selectVisibility(state);
selectChildren.forEach((selector) => selector(state));
};
select(useSessionStore.getState());
const before = agents.iterations;
const unsubscribe = useSessionStore.subscribe(select);
try {
for (let tick = 0; tick < 100; tick++) {
useSessionStore.getState().setAgentStreamState(SERVER_ID, "agent-0", {
head: [
{
kind: "assistant_message",
id: "message",
text: `token ${tick}`,
timestamp: AGENT_TIMESTAMP,
},
],
});
}
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
expect(agents.iterations - before).toBe(0);
} finally {
unsubscribe();
useSessionStore.getState().clearSession(SERVER_ID);
}
});
});
+31 -5
View File
@@ -97,6 +97,31 @@ export function selectSubagentsForParent(
return rows;
}
// Session-store selectors run on every store update, including every stream tick, which
// leaves the agent map untouched. Reuse the previous rows until the map changes, and keep
// them when a recompute yields equal rows so the hook's equality check stays O(1).
export function createSubagentsForParentSelector(
params: SelectSubagentsParams,
pendingArchiveIds: ReadonlySet<string>,
): (state: SessionStoreSnapshot) => SubagentRow[] {
let previousAgents: Map<string, Agent> | undefined;
let previousRows: SubagentRow[] | null = null;
return (state) => {
const agents = state.sessions[params.serverId]?.agents;
if (previousRows && agents === previousAgents) {
return previousRows;
}
const rows = selectSubagentsForParent(state, params, pendingArchiveIds);
previousAgents = agents;
if (!previousRows || !equal(previousRows, rows)) {
previousRows = rows;
}
return previousRows;
};
}
export function selectProviderSubagentsForParent(
state: ProviderSubagentStoreSnapshot,
params: SelectSubagentsParams,
@@ -133,12 +158,13 @@ export function selectProviderSubagentsForParent(
}
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
const paseoRows = useStoreWithEqualityFn(
useSessionStore,
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
equal,
const { serverId, parentAgentId } = params;
const pendingArchiveIds = usePendingArchiveAgentIds(serverId);
const selectPaseoRows = useMemo(
() => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds),
[serverId, parentAgentId, pendingArchiveIds],
);
const paseoRows = useStoreWithEqualityFn(useSessionStore, selectPaseoRows, equal);
const supported = useSessionStore(
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
);
+88 -64
View File
@@ -1,11 +1,12 @@
import { useCallback, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Archive, Unlink } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useProviderIcon } from "@/components/provider-icons";
import { ComposerTrackActions, ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
@@ -13,6 +14,7 @@ import {
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import type { Theme } from "@/styles/theme";
import { useAgentFactsLine } from "@/panels/agent-facts-line";
import { getPanelManifest } from "@/panels/panel-manifest";
import type { SubagentRow } from "./select";
import type { ArchiveFinishedStatus } from "./use-archive-finished";
@@ -48,7 +50,6 @@ export interface SubagentsTrackProps {
const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
/** Leading and action glyphs share one size so rows keep a single icon column. */
const ROW_ICON_SIZE = 14;
function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation {
@@ -219,18 +220,21 @@ function SubagentsTrackRow({
({ active }: { active: boolean }) => (
<>
<WorkspaceTabIcon presentation={presentation} backdrop={active ? "surface2" : "surface1"} />
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
{presentation.subtitle ? (
<Text style={styles.rowTrailing} numberOfLines={1}>
{presentation.subtitle}
<View style={styles.rowText}>
<Text style={styles.rowTitle} numberOfLines={1}>
{displayLabel}
</Text>
) : null}
{row.kind === "paseo" ? (
<PaseoSubagentFacts serverId={serverId} agentId={row.id} />
) : (
<SubagentFacts rowId={row.id} text={presentation.subtitle} />
)}
</View>
{row.kind === "paseo" ? (
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
compact={isCompact}
visible={actionsAlwaysVisible || active}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
@@ -243,10 +247,12 @@ function SubagentsTrackRow({
displayLabel,
handleArchivePress,
handleDetachPress,
isCompact,
onDetachSubagent,
presentation,
row.kind,
row.id,
serverId,
],
);
@@ -262,20 +268,38 @@ function SubagentsTrackRow({
);
}
/** The same facts line the agent's sheet shows when it opens without its composer. */
function PaseoSubagentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
const text = useAgentFactsLine(serverId, agentId);
return <SubagentFacts rowId={agentId} text={text} />;
}
function SubagentFacts({ rowId, text }: { rowId: string; text: string | null }) {
if (!text) return null;
return (
<Text style={styles.rowFacts} numberOfLines={1} testID={`subagents-track-row-facts-${rowId}`}>
{text}
</Text>
);
}
function SubagentRowActions({
rowId,
displayLabel,
compact,
visible,
onDetachPress,
onArchivePress,
}: {
rowId: string;
displayLabel: string;
compact: boolean;
visible: boolean;
onDetachPress?: () => void;
onArchivePress: () => void;
}): ReactElement {
const { t } = useTranslation();
const iconSize = paneContentToolbarIconSize(compact);
return (
<View
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
@@ -283,69 +307,59 @@ function SubagentRowActions({
>
{onDetachPress ? (
<SubagentActionButton
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
testID={`subagents-track-detach-${rowId}`}
label={t("subagents.detachAction", { label: displayLabel })}
tooltipLabel={t("subagents.detachTooltip")}
icon="detach"
visible={visible}
compact={compact}
onPress={onDetachPress}
/>
testID={`subagents-track-detach-${rowId}`}
>
<ThemedUnlink size={iconSize} uniProps={mutedIconColorMapping} />
</SubagentActionButton>
) : null}
<SubagentActionButton
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
testID={`subagents-track-archive-${rowId}`}
label={t("subagents.archiveAction", { label: displayLabel })}
tooltipLabel={t("subagents.archiveTooltip")}
icon="archive"
visible={visible}
compact={compact}
onPress={onArchivePress}
/>
testID={`subagents-track-archive-${rowId}`}
>
<ThemedArchive size={iconSize} uniProps={mutedIconColorMapping} />
</SubagentActionButton>
</View>
);
}
type SubagentActionIcon = "archive" | "detach";
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
if (icon === "detach") {
return <ThemedUnlink size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
return <ThemedArchive size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
/** The standard icon action, with a tooltip that names the action without repeating the row. */
function SubagentActionButton({
accessibilityLabel,
testID,
label,
tooltipLabel,
icon,
visible,
compact,
onPress,
testID,
children,
}: {
accessibilityLabel: string;
testID: string;
label: string;
tooltipLabel: string;
icon: SubagentActionIcon;
visible: boolean;
compact: boolean;
onPress: () => void;
testID: string;
children: ReactNode;
}): ReactElement {
const tooltip = useMemo(
() => <Text style={styles.tooltipText}>{tooltipLabel}</Text>,
[tooltipLabel],
);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={onPress}
style={styles.actionButton}
hitSlop={8}
>
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
<ToolbarButton
label={label}
tooltip={tooltip}
tooltipSide="top"
compact={compact}
onPress={onPress}
testID={testID}
>
{children}
</ToolbarButton>
);
}
@@ -360,9 +374,24 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
},
// Trailing metadata — provider context on a subagent row, progress on the archive row. No width
// cap: the panel's own ceiling bounds it. It shrinks twice as fast as the label, so a wordy
// provider subtitle gives way first instead of squeezing the thing that names the row.
// Title over facts. The facts sit under the title rather than beside it, so a long task name
// keeps the row's full width and neither line truncates the other.
rowText: {
flexGrow: 1,
flexShrink: 1,
flexBasis: "auto",
minWidth: 0,
},
rowTitle: {
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
},
rowFacts: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
// Progress on the archive row. It shrinks twice as fast as the label, so a long retry count
// gives way first instead of squeezing the thing that names the row.
rowTrailing: {
flexShrink: 2,
minWidth: 0,
@@ -381,11 +410,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
opacity: 0,
},
actionButton: {
padding: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
@@ -151,6 +151,61 @@ describe("native terminal selection", () => {
});
});
it.each([
{ col: 0, expected: "한글", startCol: 0, endCol: 3 },
{ col: 1, expected: "한글", startCol: 0, endCol: 3 },
{ col: 2, expected: "한글", startCol: 0, endCol: 3 },
{ col: 3, expected: "한글", startCol: 0, endCol: 3 },
{ col: 5, expected: "문장", startCol: 5, endCol: 8 },
{ col: 6, expected: "문장", startCol: 5, endCol: 8 },
{ col: 7, expected: "문장", startCol: 5, endCol: 8 },
{ col: 8, expected: "문장", startCol: 5, endCol: 8 },
])(
"selects the whole Korean word from column $col, including continuation cells",
async ({ col, expected, startCol, endCol }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
const row = bounds.cursorRow;
const selection = resolveTerminalWordSelection({ terminal, coordinate: { row, col } });
expect({ selection, text: extractTerminalSelectedText({ terminal, selection }) }).toEqual({
selection: {
start: { row, col: startCol },
end: { row, col: endCol },
coordinateEpoch: bounds.coordinateEpoch,
},
text: expected,
});
} finally {
terminal.dispose();
}
},
);
it.each([
{ text: "한글 문장", col: 4, expected: "" },
{ text: "(한글)", col: 2, expected: "한글" },
{ text: "漢字 test", col: 1, expected: "漢字" },
{ text: "a한b x", col: 2, expected: "a한b" },
{ text: "🙂🙂 ok", col: 1, expected: "🙂🙂" },
{ text: "e\u0301한 ok", col: 2, expected: "e\u0301한" },
{ text: "한글", col: 3, expected: "한글" },
])("preserves word boundaries in $text at column $col", async ({ text, col, expected }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write(text);
const selection = resolveTerminalWordSelection({
terminal,
coordinate: { row: terminal.getBufferBounds().cursorRow, col },
});
expect(extractTerminalSelectedText({ terminal, selection })).toBe(expected);
} finally {
terminal.dispose();
}
});
it("maps screen coordinates through the current visible window", () => {
expect(
hitTestTerminalSelectionCell({

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