Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] fcda2f1cf0 fix(hub): reconnect after silent connection loss (#7315)
A network path can swallow close notifications while the Hub socket remains open locally. Probe it with protocol ping/pong frames and feed stale sockets into the existing reconnect path.

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(usage): preserve history through discovery failures

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

Collect candidate values between equal-length backtick runs and let the Markdown engine own code boundaries. This preserves classifier decisions without reparsing long fenced blocks in JavaScript during streaming.
2026-10-18 00:30:19 +07:00
Mohamed Boudra a4eb0adcf6 test(app): consolidate overlapping Playwright journeys (#7245) 2026-10-17 23:01:36 +07:00
Mohamed Boudra 6ff071a487 Keep the compact sidebar close button beside the first nav item (#7244)
* fix(app): keep the compact sidebar close button beside the first nav row

The close button was absolutely positioned over the nav group, so it covered
the first row's label and hover fill. It now shares a flex row with the first
item, which shrinks to make room, and uses the compact header's icon button so
it gets the same size and round press highlight.

* test(app): cover the compact sidebar close button beside the first nav row
2026-10-17 22:45:08 +07:00
Mohamed Boudra ecd42015b9 Redesign the compact New workspace form as full-width field triggers (#7239)
* Redesign the compact New workspace form as full-width field triggers

Each compact field is one full-width trigger with its label inside, above the
value, so the whole field is the tap target and presses highlight. The stack
stays anchored on the composer with Isolation and Branch rows reserved, so
nothing shifts as they come and go. Import session moves into the compact
header, and the title is gone; the fields name themselves.

The form's presentational code moves out of new-workspace-screen.tsx into
screens/new-workspace/form, where desktop badges and compact fields share one
FieldTrigger.

* Restore the desktop badge pressed colour

Main pressed badges to surface0; the field-trigger extraction folded it into the surface2 hover state.

* Use base text size for compact field values

docs/design.md sets interface text at fontSize.base; hierarchy comes from the muted label above.
2026-10-17 15:27:55 +00:00
Mohamed Boudra 86817a24fc perf(app): fetch project and workspace directories concurrently (#7242) 2026-10-17 22:06:09 +07:00
paseo-bot[bot]andKiraPC 86ab49153a Let Copilot implement plans after selecting Allow All (#7234)
Selecting Allow All now moves Copilot to Agent mode before enabling allow_all, so a session that was in Plan can implement the plan.

Closes #3855.

Co-authored-by: KiraPC <pasqualecarmine.carbone@gmail.com>
2026-10-17 16:54:51 +02:00
Mohamed Boudra 6e5a4682ee fix(markdown): render own-line images as blocks (#7237)
An image alone on its line, inside a paragraph with other lines, rendered inline
on iOS and web: the library only treats sole-child paragraph images as blocks,
and with hardSoftBreaks every newline is a break node. The source adaptation now
gives such images their own paragraph (keeping quote markers and list
indentation), so all platforms render them as blocks with image margins. Images
sharing a line with text stay inline. Verified through the library's md4c wasm
parser in the renderer tests.
2026-10-17 21:36:41 +07:00
Derick WellsandMohamed Boudra f22775a8e1 Default agent-to-agent messages to steering in CLI and MCP (#5164)
* Let agents steer a running turn from send_agent_prompt and paseo send

When an agent used the send_agent_prompt MCP tool on a target that was
mid-turn, sendPromptToAgent ran without activeTurnBehavior, so the
prompt path fell through to replaceAgentRun. That cancelled the target's
active turn and every provider subagent it had in flight. The web UI,
child finish notifications and schedules already pass "steer"; the MCP
tool and the CLI had no way to ask for it.

Add an optional activeTurnBehavior input to send_agent_prompt. It
defaults to "steer" for agent-scoped callers and to "interrupt" for
top-level callers, so existing top-level behaviour is unchanged. Add
--steer to paseo send. Document both and cover them with tests.

* fix(agents): retain finish notifications during steering acknowledgement

Observe completion before dispatch and hold delivery until acceptance. A replacement discards the displaced turn observation and starts a fresh listener before its iterator runs.

* fix(agents): preserve notification ownership when follow-ups fail

Keep the previous observation until dispatch succeeds and suppress duplicate delivery during acknowledgement. Decide whether to retain the running turn from the dispatch result, including side commands requested with interrupt.

* fix(agents): deduplicate notifications across overlapping follow-ups

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-17 20:39:05 +07:00
Mohamed Boudra a0aeca967c fix(app): open explicit file links regardless of extension (#7233)
Authored Markdown destinations were filtered by the filename heuristics intended for inferred text, silently ignoring screenshot links. Resolve explicit destinations directly through the existing workspace opener so reads and failures reach the file pane.
2026-10-17 19:59:49 +07:00
paseo-bot[bot] 5384abdbaa fix(app): open Markdown preview folder links in Files (#7229)
Preview links bypassed the directory-aware opener and opened folder destinations as file tabs. Use the existing opener and explorer reveal actions, retaining preferred file placement and canceling pending checks when leaving the source tab.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 14:49:56 +02:00
180 changed files with 6848 additions and 2672 deletions
+163 -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,63 @@ 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: 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 +145,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 +177,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 +235,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 +413,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 +453,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 +470,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)
+28 -2
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.12.0-beta.1 - 2026-10-16
## 0.12.0-beta.3 - 2026-10-18
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
@@ -27,8 +27,14 @@ 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))
- 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))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
- Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam))
### Plugins
@@ -37,6 +43,9 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR))
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
### Fixed
@@ -50,7 +59,12 @@ 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 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))
@@ -59,7 +73,12 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
- Fixed OpenCode agents receiving "HTTP 500" instead of a readable error when they call a Paseo tool with invalid arguments or an unknown name ([#7183](https://github.com/getpaseo/paseo/pull/7183))
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
- Fixed background Claude subagent cards staying empty while the subagent works, including after the parent finishes ([#6242](https://github.com/getpaseo/paseo/pull/6242), [#7266](https://github.com/getpaseo/paseo/pull/7266))
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
- Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen))
@@ -81,14 +100,19 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- 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 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 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 assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed folder links in chats and Markdown previews opening an error tab or doing nothing; they now open Files with the folder's contents visible ([#7205](https://github.com/getpaseo/paseo/pull/7205), [#7210](https://github.com/getpaseo/paseo/pull/7210), [#7229](https://github.com/getpaseo/paseo/pull/7229))
- Fixed Markdown links to files such as `[Capture](capture.png)` doing nothing for some file types ([#7233](https://github.com/getpaseo/paseo/pull/7233))
- Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja))
- Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message or closing Find ([#7033](https://github.com/getpaseo/paseo/pull/7033), [#7263](https://github.com/getpaseo/paseo/pull/7263) by [@panta82](https://github.com/panta82))
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
@@ -108,6 +132,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed the Pi thinking level not updating when a Pi extension changes it ([#6741](https://github.com/getpaseo/paseo/pull/6741))
- Fixed Pi subagents from `@tintinweb/pi-subagents` staying running after their turn limit ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi))
- Fixed manual `/compact` on OMP agents showing no progress and leaving the context meter stale ([#6106](https://github.com/getpaseo/paseo/pull/6106))
- Fixed follow-ups to idle OMP agents staying queued instead of starting the agent ([#7169](https://github.com/getpaseo/paseo/pull/7169) by [@nhthung](https://github.com/nhthung))
- Fixed voice mode TTS rejecting model and voice names from OpenAI-compatible servers ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros))
- Fixed the xhigh thinking option showing "Extra high" instead of its configured label ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj))
- Fixed the Remove button going off screen for long custom model names in provider settings ([#6507](https://github.com/getpaseo/paseo/pull/6507))
@@ -132,6 +157,7 @@ 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 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))
## 0.11.4 - 2026-10-15
+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.
+6 -2
View File
@@ -4,11 +4,15 @@ 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.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
## Engine constraints
@@ -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
+1 -1
View File
@@ -1 +1 @@
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"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.1",
"version": "0.12.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/server": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"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.1",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"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.1",
"version": "0.12.0-beta.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"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.1",
"version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@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.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"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.1",
"version": "0.12.0-beta.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"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.1",
"@getpaseo/highlight": "0.12.0-beta.1",
"@getpaseo/plugin": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@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.1",
"version": "0.12.0-beta.3",
"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.1",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+376
View File
@@ -0,0 +1,376 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1296.0)
aws-sdk-core (3.257.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
rexml (~> 3.4, >= 3.4.2)
aws-sdk-kms (1.132.0)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.233.2)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.3)
cgi (0.5.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.7.20261007)
dotenv (2.8.1)
emoji_regex (3.2.3)
erb (6.0.7)
excon (1.7.3)
logger
faraday (2.15.0)
faraday-net_http (>= 2.0, < 3.6)
json
logger
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.5.0)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
fastimage (2.4.1)
fastlane (2.241.1)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
cgi (~> 0.4)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
erb (>= 4.0, < 7.0)
excon (>= 1.0.0, < 2.0.0)
faraday (~> 2.7)
faraday-cookie_jar (~> 0.0.8)
faraday-follow_redirects (~> 0.3)
faraday-multipart (~> 1.0)
faraday-retry (~> 2.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.99)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.5.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 3.4.0, < 4.0.0)
security (~> 0.3)
simctl (~> 1.6.3)
terminal-notifier (>= 3.1.0, < 4.0.0)
terminal-table (~> 4)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.109.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (1.3.1)
addressable (~> 2.9)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
multi_json (~> 1.11)
representable (~> 3.0)
retriable (>= 3.1, < 5.0)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.69.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.4.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.1)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.1)
googleauth (1.17.5)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.1.6)
domain_name (~> 0.5)
io-console (0.9.4)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.21.2)
jwt (3.3.0)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.2)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
net-http (0.10.0)
net-protocol (>= 0.3.0)
uri (>= 0.11.1)
net-protocol (0.4.0)
timeout
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pp (0.6.4)
prettyprint
prettyprint (0.2.1)
prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
representable (3.3.1)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (4.2.0)
rexml (3.4.4)
rouge (3.28.0)
rubyzip (3.7.0)
security (0.3.0)
signet (0.22.1)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (3.1.0)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
timeout (0.6.1)
trailblazer-option (0.1.2)
tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (3.3.0)
unicode-emoji (~> 4.3)
unicode-emoji (4.3.0)
uri (1.1.2)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-linux-gnu
DEPENDENCIES
fastlane (~> 2.234)
multi_json
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1296.0) sha256=70cc7a44fa4824a9df5652b853dc96aaaf09ec20642cd9d42ed7335a36cc3567
aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
aws-sdk-s3 (1.233.2) sha256=b138737a0de8b02f163ae47a9fc184ac98fc9b90926237c7d7af716a14ea1221
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
excon (1.7.3) sha256=ea13fabcab2b5f5bb64e2952a0ac018bf9882832fe7a58206630be18ba888fa9
faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.241.1) sha256=e8e97f3274d678c74d9e473b98d202621262c40dfce2b43b7a05afde7caa2ea5
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
google-apis-core (1.3.1) sha256=20ce9ee1fde0be87fe30a135dfdbd4b9ee33ba32172794c1f2770e7a45361b2f
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
google-apis-storage_v1 (0.69.0) sha256=ca5faad4a244c609bb434f81884432c449e34b31a87623f7c32b582e464ca7c8
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
google-logging-utils (0.2.1) sha256=73945c3ac6800eba802c26b1744765d5057cc4d89182c7255ee1347b0ca96dc7
googleauth (1.17.5) sha256=92498efc6883630a7e011ce4e667f887e314e588d584410ba18df49f1303f3d7
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
signet (0.22.1) sha256=6e73e9f2bdc2f038a5fdc8a89d248792b1b55e9ca50ee1ba458d8f1d9f0a2151
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
BUNDLED WITH
4.0.23
@@ -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");
+77 -1
View File
@@ -5,6 +5,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
@@ -19,6 +20,10 @@ import {
} from "../support/helpers/composer";
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
import {
expectPromptsDrawnByPlugin,
withPromptTimelinePlugin,
} from "../support/helpers/plugin-timeline";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
@@ -120,6 +125,49 @@ async function closeFind(page: Page) {
.toEqual([]);
}
async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") {
// The scrolling surface has no accessible name; use its existing test id.
const viewport = page.getByTestId("agent-chat-scroll");
await waitForTimelineGeometryToSettle(page);
const before = await viewport.evaluate((element) => element.scrollTop);
await page.keyboard.press(key);
const direction = key === "PageUp" ? -1 : 1;
await expect
.poll(
async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before),
)
.toBeGreaterThan(100);
}
test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-scroll-focus-",
title: "Chat Find keyboard scrolling",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await closeFind(page);
await expectChatKeyboardScroll(page, "PageUp");
await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") });
await expectChatKeyboardScroll(page, "PageDown");
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await page.getByRole("button", { name: "Close Find", exact: true }).click();
await expect(query(page)).toHaveCount(0);
await expectChatKeyboardScroll(page, "PageUp");
} finally {
await agent.cleanup();
}
});
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
page,
}, testInfo) => {
@@ -174,7 +222,7 @@ async function highlightedMessage(page: Page) {
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
@@ -219,6 +267,34 @@ test("counts and steps through every match in the chat, not just the selected me
}
});
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-plugin-prompt-",
title: "Chat Find plugin prompt",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await withPromptTimelinePlugin(async () => {
await openAgentRoute(page, agent);
await expectPromptsDrawnByPlugin(page);
await openChatFind(page);
await query(page).fill("scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "plugin-prompt");
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
});
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
@@ -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();
@@ -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);
@@ -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);
});
@@ -105,6 +105,37 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "screenshot-markdown-link-",
title: "Screenshot file links",
initialPrompt: "Show screenshots",
featureValues: {
mockAssistantResponse:
"[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
},
});
try {
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
await openAgentRoute(page, session);
await test.step("Open the linked screenshot", async () => {
await openAssistantMarkdownLink(page, "Capture");
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
});
await test.step("Explain why the missing screenshot cannot open", async () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "Missing capture");
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
await expect(page.getByText(/ENOENT/)).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
});
} finally {
await session.cleanup();
}
});
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
@@ -2,6 +2,63 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectFileTabOpen,
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs";
async function openMarkdownPreview(page: Page) {
await openFileExplorer(page);
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
async function followPreviewLink(page: Page, name: string) {
await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByRole("link", { name, exact: true })
.click();
}
test("Markdown preview folder links reveal Files and file links still open a file", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "preview-folder-links-" });
await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true });
await writeFile(
path.join(workspace.repoPath, "docs/guides/guide.md"),
"# Folder link destination\n",
);
await writeFile(
path.join(workspace.repoPath, "README.md"),
"[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n",
);
await workspace.navigateTo();
await openMarkdownPreview(page);
await followPreviewLink(page, "Guide");
await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible();
await openMarkdownPreview(page);
await followPreviewLink(page, "Dot");
await expectFolderContents(page);
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
await openMarkdownPreview(page);
await followPreviewLink(page, "Bare");
await expectFolderContents(page);
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") });
});
async function openReportedFolderLink(page: Page, name: string) {
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
+19 -48
View File
@@ -2,9 +2,7 @@ import { test, expect } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertNewTabMenuTriggerVisible,
assertSingleNewTabButton,
openNewTabMenuWithShortcut,
clickNewChat,
clickNewTerminal,
@@ -126,6 +124,7 @@ test.describe("Tab creation", () => {
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertNewTabMenuTriggerVisible(page);
await pressNewTabShortcut(page);
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
@@ -190,11 +189,18 @@ test.describe("Tab creation", () => {
await expect(agent).toBeFocused();
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
test("the Agent menu creates a draft and shows its composer within the visual budget", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composerLocator(page),
10_000,
);
expect(elapsed).toBeLessThan(3_000);
await expectComposerVisible(page);
const tabsAfter = await getTabTestIds(page);
@@ -202,12 +208,17 @@ test.describe("Tab creation", () => {
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
});
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
const elapsed = await measureTileTransition(
page,
() => clickNewTerminal(page),
terminalSurfaceLocator(page),
20_000,
);
expect(elapsed).toBeLessThan(5_000);
await expectTerminalSurfaceVisible(page);
const tabsAfter = await getTabTestIds(page);
@@ -295,13 +306,6 @@ test.describe("Tab creation", () => {
await profileSeed.restore();
}
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page);
await assertNewTabMenuTriggerVisible(page);
});
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -339,37 +343,4 @@ test.describe("Tab transitions (no flash)", () => {
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
});
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewTerminal(page),
terminalSurfaceLocator(page),
20_000,
);
// Terminal surface should appear within a reasonable budget.
// Note: terminal creation involves a server round-trip, so we allow more time
// than a pure in-memory transition, but it should still be well under 5 seconds.
expect(elapsed).toBeLessThan(5_000);
});
test("New agent tab click shows composer without flash", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composerLocator(page),
10_000,
);
// Draft creation is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion
// is that no blank/flash frame appears (tested above).
expect(elapsed).toBeLessThan(3_000);
});
});
@@ -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"],
});
@@ -188,3 +188,121 @@ async function measureInlineCode(message: Locator) {
return { codes, right: paragraph.getBoundingClientRect().right };
});
}
test("inline-code paths preserve stable recognition and file navigation", async ({
page,
context,
}, testInfo) => {
const { writeFile, mkdir } = await import("node:fs/promises");
const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"];
const source =
paths.map((path) => `Open \`${path}\`.`).join("\n\n") +
"\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain.";
const session = await seedMockAgentWorkspace({
repoPrefix: "inline-path-regression-",
title: "Inline path regression",
initialPrompt: "Show file paths",
featureValues: { mockAssistantResponse: source },
});
try {
await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true });
for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`);
await writeFile(
`${session.cwd}/src/index.ts`,
Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"),
);
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible();
await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0);
await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0);
for (const path of paths) {
await openInlineRegressionFile(page, path);
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText(
`Opened ${path}`,
);
}
await openInlineRegressionFile(page, "src/index.ts#L10-L20");
await expect(page.getByLabel("Line 20, column 26")).toBeVisible();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-content")
.focus();
await page.keyboard.press("ControlOrMeta+c");
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(
Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"),
);
await expect(
page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-selectionBackground"),
).not.toHaveCount(0);
await testInfo.attach("inline-path-line-selection", {
body: await page.screenshot(),
contentType: "image/png",
});
} finally {
await session.cleanup();
}
});
async function openInlineRegressionFile(page: Page, path: string) {
await page.getByRole("button", { name: "Inline path regression", exact: true }).click();
await page
.getByTestId("assistant-message")
.getByRole("link", { name: path, exact: true })
.click();
}
// Web links are styled inline by the library, so hover state has to restyle the anchor.
test("markdown links underline on hover", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "markdown-link-hover-",
title: "Markdown link hover",
featureValues: {
mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).",
},
initialPrompt: "Show links",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
for (const name of ["the docs", "README"]) {
const link = message.getByRole("link", { name, exact: true });
await expect(link).toHaveCSS("text-decoration-line", "none");
await link.hover();
await expect(link).toHaveCSS("text-decoration-line", "underline");
await page.mouse.move(0, 0);
await expect(link).toHaveCSS("text-decoration-line", "none");
}
} finally {
await session.cleanup();
}
});
const PIXEL_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
test("Markdown images show a zoom-in cursor", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "markdown-image-cursor-",
title: "Markdown image cursor",
featureValues: { mockAssistantResponse: `Before.\n\n![pixel](${PIXEL_PNG})\n\nAfter.` },
initialPrompt: "Show image",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const image = page.getByTestId("assistant-message").getByRole("button", { name: "pixel" });
await expect(image).toHaveCSS("cursor", "zoom-in");
} finally {
await session.cleanup();
}
});
@@ -223,7 +223,10 @@ test.describe("Projects settings", () => {
}
});
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
test("edits worktree setup, navigates within the host, and removes a script", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
@@ -236,15 +239,22 @@ test.describe("Projects settings", () => {
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
});
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectProjectHostContextHidden(page);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
await test.step("navigation stays inside the selected host", async () => {
await expectProjectHostContextHidden(page);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
});
await test.step("removing a script leaves the committed setup unchanged", async () => {
await expectScriptRowCount(page, 1);
await removeProjectScript(page, "dev");
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
await clickSaveProjectSettings(page);
await expectNoUncommittedSetupWarning(page);
});
});
test("user edits worktree setup on a non-GitHub remote project", async ({
@@ -259,18 +269,7 @@ test.describe("Projects settings", () => {
await expectProjectConfigSaved(gitlabRemoteProject);
});
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
});
test("reopening the edit sheet seeds from the saved project", async ({
test("renames a project, reopens the saved edit sheet, and adds an icon", async ({
page,
editableProject,
}) => {
@@ -279,26 +278,26 @@ test.describe("Projects settings", () => {
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
await openProjectEditSheet(page);
await expectProjectEditName(page, "Renamed project");
await expectProjectEditsSaveDisabled(page);
});
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
await test.step("reopening seeds the saved name with no unsaved changes", async () => {
await openProjectEditSheet(page);
await expectProjectEditName(page, "Renamed project");
await expectProjectEditsSaveDisabled(page);
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await test.step("an icon can be saved without another name change", async () => {
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
});
});
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
@@ -459,31 +458,4 @@ test.describe("Projects settings — error UX", () => {
await expectNoEditableTarget(page);
});
test("project detail does not render a second host selector", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectProjectHostContextHidden(page);
});
test("script removal via kebab menu removes the row from the form", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectScriptRowCount(page, 1);
await removeProjectScript(page, "dev");
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
await clickSaveProjectSettings(page);
await expectNoUncommittedSetupWarning(page);
});
});
@@ -116,28 +116,12 @@ async function exerciseProviderSettingsStack(page: Page) {
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expectProviderSettingsVisible(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
}
test.describe("provider settings overlay stack", () => {
test("provider model update age says just now", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-relative-time-",
title: "Provider relative time",
});
try {
await page.clock.install({ time: Date.now() });
await page.setViewportSize(MOBILE_VIEWPORT);
await openAgentRoute(page, session);
await expectComposerVisible(page);
await openProviderSettingsFromModelSelector(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await session.cleanup();
}
});
test("provider settings covers the desktop model selector without closing it", async ({
page,
}) => {
@@ -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,
});
});
});
@@ -504,6 +504,17 @@ test.describe("Mobile sidebar panelState transition", () => {
await expectMobileAgentSidebarHidden(page);
});
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
});
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
page,
}) => {
+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();
}
@@ -1,99 +0,0 @@
import { expect, test } from "../support/fixtures";
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalSurfaceVisible,
focusTerminalSurface,
typeInTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
} from "../support/helpers/terminal-perf";
interface CreatedAgentCwdAssertion {
workspaceId: string;
cwd: string | null;
}
async function fetchSingleAgentForWorkspace(
workspace: SeededWorkspace,
): Promise<CreatedAgentCwdAssertion | null> {
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
.map((entry) => entry.agent)
.filter((agent) => agent.workspaceId === workspace.workspaceId);
if (agents.length !== 1) {
return null;
}
const [agent] = agents;
return {
workspaceId: workspace.workspaceId,
cwd: agent.cwd,
};
}
test.describe("Workspace cwd correctness", () => {
test("main checkout workspace opens terminals in the project root", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
await workspace.navigateTo();
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
});
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const message = `cwd draft create ${Date.now()}`;
await expect(composer).toBeEditable({ timeout: 15_000 });
await composer.fill(message);
await composer.press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
.toEqual({
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
});
} finally {
await workspace.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
await workspace.navigateTo();
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
});
});
@@ -3,6 +3,7 @@ import {
createAgentChatFromLauncher,
createStandaloneTerminalFromLauncher,
expectTerminalCwd,
submitAgentChatAndExpectCwd,
} from "../support/helpers/workspace-lifecycle";
test.describe("Workspace lifecycle", () => {
@@ -19,8 +20,9 @@ test.describe("Workspace lifecycle", () => {
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
await workspace.navigateTo();
await test.step("creates an agent chat", async () => {
await test.step("submitting a draft creates an agent in the project root", async () => {
await createAgentChatFromLauncher(page);
await submitAgentChatAndExpectCwd(page, workspace);
});
await test.step("creates a terminal in the project root", async () => {
@@ -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");
@@ -80,13 +80,6 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** Assert the inline plus button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the New tab button is visible in the tab bar. */
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect(
@@ -132,13 +125,6 @@ export async function waitForTabWithTitle(
).toBeVisible({ timeout });
}
/** Assert the inline plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
// ─── No-flash measurement ──────────────────────────────────────────────────
/**
@@ -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,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { clickNewChat, clickNewTerminal } from "./launcher";
import { connectSeedClient } from "./seed-client";
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
function terminalSurface(page: Page) {
@@ -30,3 +31,35 @@ export async function createAgentChatFromLauncher(page: Page): Promise<void> {
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
}
export async function submitAgentChatAndExpectCwd(
page: Page,
workspace: { workspaceId: string; repoPath: string },
): Promise<void> {
const client = await connectSeedClient();
try {
const message = "cwd draft create";
await composerInput(page).fill(message);
await composerInput(page).press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect
.poll(
async () => {
const agents = await client.fetchAgents({ scope: "active" });
return agents.entries
.map((entry) => entry.agent)
.filter((agent) => agent.workspaceId === workspace.workspaceId)
.map((agent) => ({ workspaceId: agent.workspaceId, cwd: agent.cwd }));
},
{ timeout: 30_000 },
)
.toEqual([{ workspaceId: workspace.workspaceId, cwd: workspace.repoPath }]);
} finally {
await client.close();
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"private": true,
"main": "index.ts",
"scripts": {
@@ -118,8 +118,8 @@ export function ChatFind({
}, [active, isInteractive, model]);
const close = useCallback(() => {
model.close();
root.current?.focus({ preventScroll: true });
}, [model]);
viewportRef.current?.focus?.();
}, [model, viewportRef]);
let status = "";
if (state.phase === "searching") status = t("paneFind.searching");
else if (state.phase === "loading") status = t("paneFind.loading");
@@ -76,3 +76,49 @@ it("keeps semantic Markdown paragraphs separate while finding inline matches", (
]);
expect(findRenderedMatches(row, ".another")).toEqual([]);
});
function pluginRow(html: string) {
const row = document.createElement("div");
row.dataset.pluginRow = "true";
row.dataset.messageId = "message-1";
row.innerHTML = html;
document.body.append(row);
return row;
}
it("searches a plugin row that sets no text marker, bounding matches by view", () => {
const row = pluginRow(
"<div><div>Prompt</div><div>find <span>this</span> needle</div><div>needle</div><button>needle</button></div>",
);
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([
"needle",
"needle",
]);
expect(findRenderedMatches(row, "Promptfind")).toEqual([]);
expect(findRenderedMatches(row, "find this needle")).toHaveLength(1);
expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2);
});
it("leaves rows without the plugin marker limited to marked message text", () => {
const row = document.createElement("div");
row.innerHTML = "<div>needle</div>";
document.body.append(row);
expect(findRenderedMatches(row, "needle")).toEqual([]);
});
it("counts only drawn text in a plugin row, including an opened popover", () => {
const row = pluginRow(
'<div><span>visible needle</span><div hidden>hidden needle</div><div style="display:none">display needle</div><span style="visibility:hidden">visibility needle</span><span style="opacity:0">transparent needle</span><div popover="manual">popover needle</div></div>',
);
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]);
row.querySelector<HTMLElement>("[popover]")!.showPopover();
expect(findRenderedMatches(row, "needle")).toHaveLength(2);
});
it("keeps text around nested plugin blocks in separate occurrences", () => {
const row = pluginRow("<div>alpha <div>details</div>beta</div><div>gamma <div></div>delta</div>");
expect(findRenderedMatches(row, "alpha beta")).toEqual([]);
expect(findRenderedMatches(row, "gamma delta")).toEqual([]);
expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]);
expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]);
});
@@ -21,28 +21,55 @@ export function findMessageMatches(
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
}
function renderedTextGroups(row: HTMLElement): Text[][] {
const groups: Text[][] = [];
// A plugin renderer owns the DOM of the message it replaced and sets no markers, so the
// whole row is searched and each div (a React Native View or Text) bounds a match.
const pluginRow = row.dataset.pluginRow === "true";
const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS;
const contents = pluginRow
? [row]
: row.querySelectorAll<HTMLElement>('[data-message-text="true"]');
for (const content of contents) {
let group: { block: Element; texts: Text[] } | undefined;
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
);
let node: Node | null;
while ((node = walker.nextNode())) {
if (node instanceof Element) {
// Even an empty nested block separates the text before and after it.
if (node.matches(blocks)) group = undefined;
continue;
}
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
if (
pluginRow &&
!node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true })
)
continue;
const block = node.parentElement?.closest(blocks) ?? content;
if (group?.block !== block) {
group = { block, texts: [] };
groups.push(group.texts);
}
group.texts.push(node);
}
}
return groups;
}
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
if (!query.trim()) return [];
const groups = new Map<Element, Text[]>();
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
const block = node.parentElement?.closest(BLOCKS) ?? content;
const texts = groups.get(block) ?? [];
texts.push(node);
groups.set(block, texts);
}
}
const pattern = query
.trim()
.split(/\s+/)
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+");
const ranges: Range[] = [];
for (const texts of groups.values()) {
for (const texts of renderedTextGroups(row)) {
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
let text = "";
for (const point of points) {
@@ -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
@@ -1162,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
const handle: StreamViewportHandle = {
focus: () => scrollContainerRef.current?.focus({ preventScroll: true }),
scrollToBottom: () => {
setFollowOutput(true);
cancelPendingStickToBottom();
@@ -1250,6 +1251,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
style={streamRowStyle}
>
<StreamRowContent
@@ -1267,6 +1269,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
style={streamRowStyle}
>
<StreamRowContent
@@ -1329,6 +1332,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
data-index={virtualRow.index}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
@@ -51,6 +51,8 @@ export interface ScrollToMessageOccurrence {
}
export interface StreamViewportHandle {
/** Give keyboard navigation back to the scrolling surface on web. */
focus?: () => void;
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
+2 -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
+9 -15
View File
@@ -23,6 +23,8 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/** Authored links identify a file; only inferred text needs filename heuristics. */
explicitLink?: boolean;
/**
* True only when `value` is a markdown href — the renderer percent-encodes
* non-ASCII and space characters, so the path must be decoded. Raw path
@@ -30,8 +32,6 @@ export interface AssistantHrefParseOptions {
* send a real file named `my%20file.md` to `my file.md`.
*/
decodeHref?: boolean;
/** Explicit Markdown links can name nested folders without a filename extension. */
allowExtensionlessNestedPaths?: boolean;
}
export type AssistantFileLinkClassification =
@@ -236,7 +236,7 @@ export function classifyAssistantFileLink(
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -280,7 +280,7 @@ export function parseAssistantFileLink(
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
allowExtensionlessNestedPaths: options.allowExtensionlessNestedPaths,
explicitLink: options.explicitLink,
});
if (relativeTarget) {
return relativeTarget;
@@ -414,7 +414,7 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref" | "allowExtensionlessNestedPaths"> = {},
options: Pick<AssistantHrefParseOptions, "decodeHref" | "explicitLink"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
@@ -440,7 +440,7 @@ function parseLocalPathParts(
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!isPlausibleAssistantLocalPath(pathPart, options)) {
if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) {
return null;
}
return {
@@ -461,7 +461,7 @@ function parseLocalPathParts(
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
if (!isPlausibleAssistantLocalPath(plausiblePath, options)) {
if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) {
return null;
}
@@ -586,10 +586,7 @@ function getHeuristicLocalPath(value: string): string | null {
return beforeHash;
}
function isPlausibleAssistantLocalPath(
pathValue: string,
options: Pick<AssistantHrefParseOptions, "allowExtensionlessNestedPaths"> = {},
): boolean {
function isPlausibleAssistantLocalPath(pathValue: string): boolean {
const normalized = normalizePathToken(pathValue);
if (!normalized) {
return false;
@@ -613,10 +610,7 @@ function isPlausibleAssistantLocalPath(
if (segments.length > 1) {
const lastSegment = segments[segments.length - 1];
return (
!isDomainLikePathSegment(firstSegment) &&
(options.allowExtensionlessNestedPaths === true || isPlausibleAssistantFileName(lastSegment))
);
return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment);
}
return isPlausibleAssistantFileName(firstSegment);
@@ -50,7 +50,7 @@ it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])(
},
);
it.each(["example.com/guides", "docs/guides?view=all"])(
it.each(["docs/guides?view=all"])(
"does not turn URL-like hrefs into workspace paths: %s",
(href) => {
expect(classifyForResolution({ href }, CONTEXT)).toEqual({
@@ -60,6 +60,21 @@ it.each(["example.com/guides", "docs/guides?view=all"])(
},
);
it.each([
".dev/tablet-review/tablet-agent-draft.png",
"images/capture.jpg",
"downloads/archive.custom",
"assets/LICENSE",
"example.com/guides",
"capture.png",
"missing.png",
])("opens explicit file link %s without filename guessing", (href) => {
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: `${CONTEXT.workspaceRoot}/${href}` } },
});
});
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
try {
@@ -117,7 +117,7 @@ export function classifyForResolution(
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
allowExtensionlessNestedPaths: tokenInfo.fromHref && !isLinkifiedSource(source),
explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source),
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
+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 });
@@ -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],
);
+49 -42
View File
@@ -21,8 +21,14 @@ import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
@@ -62,6 +68,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import {
@@ -74,6 +81,8 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
const ThemedX = withUnistyles(X);
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
@@ -545,6 +554,11 @@ function MobileSidebar({
closeSidebar();
}, [closeSidebar]);
const closeButton = useMemo(
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
[labels.closeSidebar, closeSidebar],
);
const mobileSidebarInsetStyle = useMemo(
() => ({
paddingTop: insetsTop,
@@ -562,30 +576,11 @@ function MobileSidebar({
>
<View style={styles.sidebarContent} pointerEvents="auto">
<WindowChromeSafeArea placement="below" />
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
<WindowChromeSafeArea
placement="inline"
pointerEvents="box-none"
style={styles.mobileCloseButtonRow}
>
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={labels.closeSidebar}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={theme.iconSize.md}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</WindowChromeSafeArea>
<SidebarNavRows
style={styles.sidebarHeaderGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
@@ -626,6 +621,34 @@ function MobileSidebar({
);
}
const NO_SHORTCUT: ShortcutKey[] = [];
/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={styles.mobileCloseButton}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={label}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
function DesktopSidebar({
theme,
workspaceGroups,
@@ -856,24 +879,8 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
mobileCloseButtonRow: {
position: "absolute",
top: theme.spacing[3],
left: 0,
right: 0,
zIndex: 2,
alignItems: "flex-end",
},
mobileCloseButton: {
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
// This optical inset puts their painted right edges on the same sidebar rail.
marginRight: theme.spacing[2] + 1.5,
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surfaceSidebar,
marginRight: theme.spacing[2],
},
desktopSidebarBorder: {
borderRightWidth: 1,
@@ -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>
);
}
@@ -1,5 +1,10 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
import { QueryClient } from "@tanstack/react-query";
import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding";
import { describe, expect, it } from "vitest";
@@ -31,6 +36,56 @@ describe("shared Markdown source adaptation", () => {
"> - ![Image](<file:///cache/a.png>)\n\n[picture]: ../a.png",
);
});
it("gives an image alone on its line its own paragraph", () => {
expect(
prepareMarkdownSource("Before the shot:\n![Shot](https://x/a.png)\nAfter it.").render(),
).toBe("Before the shot:\n\n![Shot](https://x/a.png)\n\nAfter it.");
expect(prepareMarkdownSource("![A](https://x/a.png)\n![B](https://x/b.png)").render()).toBe(
"![A](https://x/a.png)\n\n![B](https://x/b.png)",
);
expect(
prepareMarkdownSource(
"Line one\\\n[![Linked](https://x/a.png)](https://x)\nLine three",
).render(),
).toBe("Line one\n\n[![Linked](https://x/a.png)](https://x)\n\nLine three");
});
it("keeps images that share a line with text inline", () => {
const source = "Icon ![i](https://x/i.png) in text\n![a](https://x/a.png) caption\nNext line";
expect(prepareMarkdownSource(source).render()).toBe(source);
});
it("splits own-line images inside quotes and list items without ending the container", () => {
expect(prepareMarkdownSource("> Quote text\n> ![Shot](https://x/a.png)\n> More").render()).toBe(
"> Quote text\n>\n> ![Shot](https://x/a.png)\n>\n> More",
);
expect(
prepareMarkdownSource("- Item text\n ![Shot](https://x/a.png)\n More\n- Next").render(),
).toBe("- Item text\n\n ![Shot](https://x/a.png)\n\n More\n- Next");
expect(prepareMarkdownSource("- ![Shot](https://x/a.png)\n Caption line").render()).toBe(
"- ![Shot](https://x/a.png)\n\n Caption line",
);
});
it("renders own-line images as blocks through the library parser", async () => {
const source =
"Before:\n![Shot](https://x/a.png)\nIcon ![i](https://x/i.png) in text\n> Quoted\n> ![Q](https://x/q.png)";
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
markInlineImages(tree);
const images: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Image") images.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(
images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]),
).toEqual([
["https://x/a.png", false],
["https://x/i.png", true],
["https://x/q.png", false],
]);
});
it("escapes spoiler delimiters only in prose", () => {
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
"Show \\|\\|words\\|\\| and `||code||`.",
@@ -59,7 +114,7 @@ it.each([
"src/file.ts:10-20",
"src/file.ts(10,2)",
])("recognizes inline paths through the file resolver: %s", (path) => {
const prepared = prepareMarkdownSource(`Open \`${path}\`.`);
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/repo");
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
expect(prepared.linkSource(path, "inline-code")).toEqual({
href: path,
@@ -68,6 +123,106 @@ it.each([
});
});
it("preserves stable inline-code file classification through the library parser", async () => {
const paths = [
"src/app/[id].tsx",
"src/app/(tabs)/index.tsx",
"src/a$b.ts",
".gitignore",
"src/index.ts#L10-L20",
"src/index.ts:10-20",
"src/index.ts:20-10",
"src/index.ts#L0",
"src/index.ts?raw",
"before/after",
"word.word",
"README.md",
"./scripts/run",
"src/file.ts(10,2)",
"src/file.ts lines 10-20",
"/tmp/[route].tsx",
"file:///tmp/file.ts#L10-L20",
];
const source = paths.map((path) => `\`${path}\``).join(" and ");
const prepared = prepareMarkdownSource(source, "/repo");
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const links: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link") links.push(String(node.attributes?.url));
node.children?.forEach(collect);
}
collect(tree);
expect(links).toEqual([
"src/app/[id].tsx",
"src/app/(tabs)/index.tsx",
"src/a$b.ts",
".gitignore",
"src/index.ts#L10-L20",
"src/index.ts:10-20",
"README.md",
"./scripts/run",
"src/file.ts(10,2)",
"/tmp/[route].tsx",
"file:///tmp/file.ts#L10-L20",
]);
expect(
classifyAssistantFileLink("src/index.ts#L10-L20", { workspaceRoot: "/repo" }),
).toMatchObject({ target: { path: "/repo/src/index.ts", lineStart: 10, lineEnd: 20 } });
});
it("lets the engine own code boundaries while classifying candidate values", async () => {
const source = [
"```ts\n`src/fenced.ts`\n```",
"`` src/app/[id].tsx `` and ` src/spaced.ts `",
"``src/a`b.ts`` and `src/after.ts`",
"\\`src/escaped.ts` and `src/final.ts`",
"`\nsrc/newline.ts\n`",
"[`src/explicit.ts`](https://example.com)",
].join("\n\n");
const prepared = prepareMarkdownSource(source, "/repo");
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const recognized: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
recognized.push(String(node.attributes.url));
}
node.children?.forEach(collect);
}
collect(tree);
expect(recognized).toEqual([
"src/app/[id].tsx",
" src/spaced.ts ",
"src/a`b.ts",
"src/after.ts",
"src/escaped.ts",
"src/final.ts",
"src/newline.ts",
]);
});
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;
expect(withoutWorkspace.test("src/app/[id].tsx")).toBe(false);
expect(withoutWorkspace.test("README.md")).toBe(false);
expect(withoutWorkspace.test("/tmp/file.ts")).toBe(true);
const prepared = prepareMarkdownSource(source, "/repo");
const native = toNativeLinkRecognition(prepared.linkRecognition);
const pattern = new RegExp(native.inlineCode.pattern);
expect(pattern.test("src/app/[id].tsx")).toBe(true);
expect(pattern.test("src/a+b.ts")).toBe(true);
expect(pattern.test("src/app/i.tsx")).toBe(false);
expect(pattern.test("src/ab.ts")).toBe(false);
});
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
@@ -152,7 +307,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,
@@ -182,7 +337,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,
@@ -209,7 +364,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,
);
@@ -276,7 +431,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);
},
);
@@ -436,7 +591,7 @@ it.each([
"×/÷",
"2024/10/16",
])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then continue.`);
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then \`${token}\`.`, "/repo");
expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
expect(linkRecognition.inlineCode.test(token)).toBe(false);
});
@@ -461,12 +616,11 @@ it.each([
"123.ts",
"FILE.TS",
"https://example.com/path",
])("recognizes complete paths and URLs in prose and inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then continue.`);
])("recognizes complete paths and URLs in prose: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then \`${token}\`.`, "/repo");
expect(
[...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text),
).toEqual([token]);
expect(linkRecognition.inlineCode.test(token)).toBe(true);
});
it.each([
@@ -477,10 +631,13 @@ it.each([
"packages/app",
"123/file",
"src/123",
])("requires stronger path intent in prose while preserving inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Compare ${token}, then continue.`);
])("leaves extensionless relative paths unlinked in prose and inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(
`Compare ${token}, then \`${token}\`.`,
"/repo",
);
expect([...`Compare ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
expect(linkRecognition.inlineCode.test(token)).toBe(true);
expect(linkRecognition.inlineCode.test(token)).toBe(false);
});
it("keeps prose and inline-code recognition enabled through the native regex adapter", () => {
@@ -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,8 +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);
// 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);
const WEB_CONTAINER = { userSelect: "text" as const };
export interface MarkdownRendererProps extends MarkdownImageContext {
@@ -43,21 +47,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 +89,35 @@ 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}
containerStyle={isWeb ? WEB_CONTAINER : undefined}
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>
);
+75 -8
View File
@@ -1,3 +1,4 @@
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions";
import { normalizeMarkdownHtml } from "./html";
import { gfmTable } from "micromark-extension-gfm-table";
@@ -5,7 +6,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
import type { AssistantFileLinkSource } from "@/assistant-file-links";
import { decodeString } from "micromark-util-decode-string";
import { fromMarkdown } from "mdast-util-from-markdown";
import type { Link, Nodes, Root } from "mdast";
import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
@@ -22,7 +23,6 @@ const SEPARATED_PATH = String.raw`${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`;
const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
// Bare prose paths need a known extension, a line suffix, or at least two separators.
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
const INLINE_CODE_PATH = String.raw`${PATH_PREFIX}?${HAS_PATH_LETTER}${SEPARATED_PATH}`;
function createFilePattern(path: string): RegExp {
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
@@ -33,20 +33,46 @@ function createFilePattern(path: string): RegExp {
}
const FILE_PATTERN = createFilePattern(PROSE_PATH);
const INLINE_CODE_FILE_PATTERN = createFilePattern(INLINE_CODE_PATH);
function linkMetadata() {
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
// The engine accepts regexes on both platforms. Classify complete code spans
// first, then pass literal matches rather than maintaining a second path grammar.
const alternatives = [...inlineCodePaths].map((path) =>
path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
return {
linkRecognition: {
text: FILE_PATTERN,
inlineCode: new RegExp(
String.raw`^(?:${INLINE_CODE_FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
),
},
linkSource: markdownLinkSource,
};
}
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
const paths = new Set<string>();
const previousEnds = new Map<number, number>();
// Collect candidates between consecutive runs of equal length. The engine owns
// Markdown context (escapes, fences, authored links); extra candidates are harmless
// because recognition only applies to complete Code nodes. This avoids an AST
// parse on every streaming reveal, including updates inside long code fences.
for (const match of source.matchAll(/`+/g)) {
const length = match[0].length;
const previousEnd = previousEnds.get(length);
previousEnds.set(length, match.index + length);
if (previousEnd === undefined) continue;
let value = source.slice(previousEnd, match.index).replace(/\r\n?|\n/g, " ");
// CommonMark code spans replace line endings and strip one surrounding space.
if (value.startsWith(" ") && value.endsWith(" ") && /[^ ]/.test(value)) {
value = value.slice(1, -1);
}
const classification = classifyAssistantFileLink(value, { workspaceRoot });
if (classification && classification.kind !== "external") paths.add(value);
}
return paths;
}
export function markdownLinkSource(
url: string,
sourceType?: "markdown" | "text" | "inline-code",
@@ -90,6 +116,7 @@ export function prepareMarkdownSource(
extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
}));
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
const edits: { start: number; end: number; text: string }[] = [];
if (
!source.includes("![") &&
@@ -97,7 +124,7 @@ export function prepareMarkdownSource(
!source.includes("&") &&
!(workspaceRoot && source.includes("]("))
) {
return { images, ...linkMetadata(), render: () => source };
return { images, ...metadata, render: () => source };
}
const definitions = new Map<string, { url: string; title?: string | null }>();
@@ -130,10 +157,50 @@ export function prepareMarkdownSource(
end,
});
}
// Line boundaries already turned into paragraph breaks, by offset of their newline.
const splitBoundaries = new Set<number>();
function isImageLike(node: PhrasingContent): boolean {
if (node.type === "image" || node.type === "imageReference") return true;
return (
(node.type === "link" || node.type === "linkReference") &&
node.children.length === 1 &&
isImageLike(node.children[0])
);
}
// An image alone on its line becomes its own paragraph, so every platform renders
// it as a block. The library only treats sole-child paragraph images as blocks;
// with hardSoftBreaks every newline is a break node, which made these inline.
function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) {
for (const child of paragraph.children) {
const childStart = child.position?.start.offset;
const childEnd = child.position?.end.offset;
if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue;
const lineStart = source.lastIndexOf("\n", childStart - 1) + 1;
const newline = source.indexOf("\n", childEnd);
const lineEnd = newline === -1 ? source.length : newline;
const prefix = source.slice(lineStart, childStart);
const suffix = source.slice(childEnd, lineEnd);
const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix);
const aloneAfter = /^\s*\\?$/.test(suffix);
if (!aloneBefore || !aloneAfter) continue;
// Continuation lines inside quotes and lists keep their quote markers on the blank line.
const blank = prefix.replace(/[^>]/g, " ").trimEnd();
const split = (boundary: number) => {
if (splitBoundaries.has(boundary)) return;
splitBoundaries.add(boundary);
// A trailing backslash was a hard break; at a paragraph end it would render literally.
const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary;
edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` });
};
if (lineStart > start) split(lineStart - 1);
if (lineEnd < end) split(lineEnd);
}
}
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null)
@@ -166,7 +233,7 @@ export function prepareMarkdownSource(
visit(getTree());
return {
images,
...linkMetadata(),
...metadata,
render(
urls: ReadonlyMap<string, string> = new Map(),
unavailableImages: ReadonlySet<string> = new Set(),
@@ -4,23 +4,23 @@ import { isWeb } from "@/constants/platform";
export function createMarkdownStyles(
theme: Theme,
compact = false,
{ compact = false, blockGap }: { compact?: boolean; blockGap: number },
): { markdownStyle: MarkdownStyle; selectionColor: string } {
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 {
selectionColor: theme.colors.terminal.selectionBackground,
@@ -93,8 +93,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;
@@ -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)
);
}
@@ -1,8 +1,9 @@
import { router, usePathname } from "expo-router";
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
import { memo, useCallback, useMemo, useRef, type ComponentType } from "react";
import { memo, useCallback, useMemo, useRef, type ComponentType, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { PluginSidebarItem } from "@/plugins/sidebar-items";
@@ -30,37 +31,49 @@ interface SidebarNavRowProps {
interface SidebarNavRowsProps extends SidebarNavRowProps {
/** Style for the group wrapper, which the sidebar owns. */
style?: StyleProp<ViewStyle>;
/** Sits to the right of the first row, which shrinks to make room. */
firstRowTrailing?: ReactNode;
}
/**
* Top-level sidebar navigation, ordered and filtered by the user's
* `sidebarNavItems` preference. Renders nothing — not even the bordered group
* wrapper — when every item is hidden.
* wrapper — when every item is hidden and there is no `firstRowTrailing`.
*/
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
export function SidebarNavRows({ style, onBeforeNavigate, firstRowTrailing }: SidebarNavRowsProps) {
const { items } = useSidebarNavItems("header");
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
const groupRef = useRef<View | null>(null);
if (visibleItems.length === 0) return null;
if (visibleItems.length === 0 && !firstRowTrailing) return null;
const [firstRow, ...restRows] = visibleItems.map((item) => {
if (item.kind === "plugin") {
return (
<PluginSidebarItem
key={item.key}
group={item.group}
section="header"
fallbackAnchorRef={groupRef}
onBeforeNavigate={onBeforeNavigate}
/>
);
}
const Row = BUILTIN_ROWS[item.id];
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
});
return (
<View ref={groupRef} collapsable={false} style={style}>
{visibleItems.map((item) => {
if (item.kind === "plugin") {
return (
<PluginSidebarItem
key={item.key}
group={item.group}
section="header"
fallbackAnchorRef={groupRef}
onBeforeNavigate={onBeforeNavigate}
/>
);
}
const Row = BUILTIN_ROWS[item.id];
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
})}
{firstRowTrailing ? (
<View style={styles.firstRow}>
<View style={styles.firstRowItem}>{firstRow}</View>
{firstRowTrailing}
</View>
) : (
firstRow
)}
{restRows}
</View>
);
}
@@ -179,3 +192,14 @@ const BUILTIN_ROWS: Record<BuiltinSidebarNavId, ComponentType<SidebarNavRowProps
search: SidebarSearchRow,
schedules: SidebarSchedulesRow,
};
const styles = StyleSheet.create({
firstRow: {
flexDirection: "row",
alignItems: "center",
},
firstRowItem: {
flex: 1,
minWidth: 0,
},
});
@@ -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>();
@@ -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 () => {
@@ -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(() => {
+2 -1
View File
@@ -1334,8 +1334,9 @@ export const ar: TranslationResources = {
},
fields: {
project: "المشروع",
base: "الأساس",
branch: "الفرع",
baseNotApplicable: "غير قابل للتطبيق",
host: "المضيف",
},
titlePlaceholder: "العنوان (اختياري)",
errors: {
+2 -1
View File
@@ -1342,8 +1342,9 @@ export const en = {
},
fields: {
project: "Project",
base: "Base",
branch: "Branch",
baseNotApplicable: "Not applicable",
host: "Host",
},
titlePlaceholder: "Title (optional)",
errors: {
+2 -1
View File
@@ -1370,8 +1370,9 @@ export const es: TranslationResources = {
},
fields: {
project: "Proyecto",
base: "Base",
branch: "Rama",
baseNotApplicable: "No aplicable",
host: "Host",
},
titlePlaceholder: "Título (opcional)",
errors: {
+2 -1
View File
@@ -1363,8 +1363,9 @@ export const fr: TranslationResources = {
},
fields: {
project: "Projet",
base: "Base",
branch: "Branche",
baseNotApplicable: "Non applicable",
host: "Hôte",
},
titlePlaceholder: "Titre (facultatif)",
errors: {
+2 -1
View File
@@ -1348,8 +1348,9 @@ export const ja: TranslationResources = {
},
fields: {
project: "プロジェクト",
base: "ベース",
branch: "ブランチ",
baseNotApplicable: "該当なし",
host: "ホスト",
},
titlePlaceholder: "タイトル(任意)",
errors: {
+2 -1
View File
@@ -1341,8 +1341,9 @@ export const ko: TranslationResources = {
},
fields: {
project: "프로젝트",
base: "기준",
branch: "브랜치",
baseNotApplicable: "해당 없음",
host: "호스트",
},
titlePlaceholder: "제목(선택 사항)",
errors: {
+2 -1
View File
@@ -1360,8 +1360,9 @@ export const ptBR: TranslationResources = {
},
fields: {
project: "Projeto",
base: "Base",
branch: "Branch",
baseNotApplicable: "Não aplicável",
host: "Host",
},
titlePlaceholder: "Título (opcional)",
errors: {
+2 -1
View File
@@ -1352,8 +1352,9 @@ export const ru: TranslationResources = {
},
fields: {
project: "Проект",
base: "Базовая ветка",
branch: "Ветка",
baseNotApplicable: "Неприменимо",
host: "Хост",
},
titlePlaceholder: "Заголовок (необязательно)",
errors: {
+2 -1
View File
@@ -1323,8 +1323,9 @@ export const zhCN: TranslationResources = {
},
fields: {
project: "项目",
base: "基线",
branch: "分支",
baseNotApplicable: "不适用",
host: "主机",
},
titlePlaceholder: "标题(可选)",
errors: {
@@ -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 };
}
@@ -1207,6 +1207,60 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it.each(["projects", "workspaces"] as const)(
"requests projects and workspaces together and commits once when %s responds first",
async (firstResponse) => {
const serverId = `parallel-directory-${firstResponse}`;
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, projectList: true },
});
const releaseProjects = client.holdProjectFetch();
const releaseWorkspaces = client.holdWorkspaceFetch();
const project = {
projectId: "parallel-project",
projectDisplayName: "Project baseline",
projectRootPath: "/repo/parallel",
projectKind: "git" as const,
};
const projects: ProjectListResult = { requestId: "projects", projects: [project] };
const workspaces: WorkspaceFetchResult = {
requestId: "workspaces",
entries: [createWorkspaceEntry("parallel-workspace")],
emptyProjects: [{ ...project, projectDisplayName: "Newer workspace overlay" }],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
};
const refresh = directory.refreshWorkspaces();
try {
await expect.poll(() => client.listProjectsCalls).toBe(1);
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
if (firstResponse === "projects") releaseProjects(projects);
else releaseWorkspaces(workspaces);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(useSessionStore.getState().sessions[serverId].workspaces.size).toBe(0);
expect(useSessionStore.getState().sessions[serverId].projects.size).toBe(0);
releaseProjects(projects);
releaseWorkspaces(workspaces);
await refresh;
const session = useSessionStore.getState().sessions[serverId];
expect(session.workspaces.has("parallel-workspace")).toBe(true);
expect(session.projects.get(project.projectId)?.projectDisplayName).toBe(
"Newer workspace overlay",
);
} finally {
releaseProjects(projects);
releaseWorkspaces(workspaces);
await refresh;
directory.dispose();
}
},
);
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
const serverId = "superseded-workspace-baseline";
const { client, directory } = createDirectory(serverId);
@@ -563,16 +563,20 @@ export class DirectorySync {
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
const supportsProjectList = serverInfo?.features?.projectList === true;
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
if (supportsProjectList) {
await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync);
}
await this.fetchWorkspaceSnapshot(
client,
source,
transaction,
input?.subscribe === true,
supportsDirectorySync,
);
const projectsReady = supportsProjectList
? this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync)
: Promise.resolve();
await Promise.all([
projectsReady,
this.fetchWorkspaceSnapshot(
client,
source,
transaction,
input?.subscribe === true,
supportsDirectorySync,
projectsReady,
),
]);
if (!supportsProjectList) {
this.buildLegacyProjectSnapshot(transaction.snapshot);
(transaction.snapshot.syncModes ??= {}).projects =
@@ -592,6 +596,7 @@ export class DirectorySync {
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
initialSubscribe: boolean,
supportsDirectorySync: boolean,
projectsReady: Promise<void>,
): Promise<void> {
let cursor: string | null = null;
let subscribe = initialSubscribe;
@@ -618,6 +623,9 @@ export class DirectorySync {
});
payload = await subscription.ready;
} else payload = await client.fetchWorkspaces(query);
// Fetch both directories together, but apply the project baseline before the
// workspace page's empty-project overlays, regardless of response order.
await projectsReady;
this.assertWorkspaceTransactionCurrent(client, source, transaction);
applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null);
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ICON_SIZE, type Theme } from "@/styles/theme";
/**
* `badge` is the desktop meta row: a borderless pill that names its field in a tooltip.
* `field` is the compact form: a full-width filled box with the field's label inside, above the
* value, so the whole field is one tap target.
*/
export type FieldTriggerVariant = "badge" | "field";
const ThemedChevronDown = withUnistyles(ChevronDown);
const extraMutedColor = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
// Extra-muted: the chevron is an affordance, not information, and sits behind its label.
function Chevron(): ReactElement {
return (
<View style={styles.chevron}>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColor} />
</View>
);
}
const chevron = <Chevron />;
export interface FieldTriggerContentProps {
variant: FieldTriggerVariant;
/** The field's name, shown inside a `field`. */
label: string;
icon: ReactNode;
value: string;
/** Secondary text after the value, such as a terminal profile's name. */
detail?: string;
}
export function FieldTriggerContent({
variant,
label,
icon,
value,
detail,
}: FieldTriggerContentProps): ReactElement {
const valueLine = (
<>
<View style={styles.iconBox}>{icon}</View>
<Text style={variant === "field" ? styles.fieldValue : styles.value} numberOfLines={1}>
{value}
{detail ? <Text style={styles.detail}> {detail}</Text> : null}
</Text>
</>
);
if (variant === "badge") {
return (
<>
{valueLine}
{chevron}
</>
);
}
return (
<>
<View style={styles.fieldBody}>
<Text style={styles.fieldLabel} numberOfLines={1}>
{label}
</Text>
<View style={styles.fieldValueLine}>{valueLine}</View>
</View>
{chevron}
</>
);
}
/** Pressable style for a field trigger, for triggers that bring their own Pressable. */
export function useFieldTriggerStyle(variant: FieldTriggerVariant, disabled: boolean) {
return useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) => [
variant === "field" ? styles.field : styles.badge,
!disabled && Boolean(state.hovered) && styles.highlighted,
!disabled &&
state.pressed &&
(variant === "field" ? styles.highlighted : styles.badgePressed),
disabled && styles.disabled,
],
[disabled, variant],
);
}
export interface FieldTriggerProps extends FieldTriggerContentProps {
/** What a screen reader announces; defaults to `label`. */
accessibilityLabel?: string;
onPress: () => void;
disabled?: boolean;
/** Desktop hover hint; badges only, since a field shows its label. */
tooltip?: string;
testID?: string;
}
/** A field trigger that anchors a Combobox. */
export const FieldTrigger = forwardRef<View, FieldTriggerProps>(function FieldTrigger(
{ accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content },
ref,
): ReactElement {
const style = useFieldTriggerStyle(content.variant, disabled);
const trigger = (
<ComboboxTrigger
ref={ref}
chevron={null}
block={content.variant === "field"}
testID={testID}
onPress={onPress}
disabled={disabled}
style={style}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? content.label}
>
<FieldTriggerContent {...content} />
</ComboboxTrigger>
);
if (content.variant === "field" || !tooltip) return trigger;
return (
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
{trigger}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
});
// Height of a badge. The desktop row sizes from it.
export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.tight;
// Height of a compact field: label line plus value line. The compact stack reserves rows from it.
export const FIELD_HEIGHT = 56;
const styles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
height: FIELD_BADGE_HEIGHT,
maxWidth: 240,
overflow: "hidden",
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
},
field: {
flexDirection: "row",
alignItems: "center",
height: FIELD_HEIGHT,
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface1,
gap: theme.spacing[2],
},
highlighted: {
backgroundColor: theme.colors.surface2,
},
badgePressed: {
backgroundColor: theme.colors.surface0,
},
disabled: {
opacity: 0.6,
},
// Optical: the chevron's ink sits high in its box.
chevron: {
flexShrink: 0,
transform: [{ translateY: 1 }],
},
iconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
value: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
fieldBody: {
flex: 1,
minWidth: 0,
gap: theme.spacing[0.5],
},
fieldLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
fieldValueLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
fieldValue: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
},
detail: {
color: theme.colors.foregroundExtraMuted,
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
}));
@@ -0,0 +1,49 @@
import type { ReactElement } from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export const folderIcon = <ThemedFolder size={ICON_SIZE.sm} uniProps={mutedColor} />;
export const addProjectIcon = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedColor} />;
export const branchIcon = <ThemedGitBranch size={ICON_SIZE.sm} uniProps={mutedColor} />;
export const pullRequestIcon = <ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={mutedColor} />;
// Single uppercase initial inside an iconSize.md (16px) square — below the smallest font-size
// token, so it stays a literal sized to the box.
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
export function ProjectIcon({
projectViewKey,
projectName,
iconDataUri,
}: {
projectViewKey: string;
projectName: string;
iconDataUri: string | null;
}): ReactElement {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
return (
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderLabel.charAt(0).toUpperCase() || "?"}
projectViewKey={projectViewKey}
size={ICON_SIZE.md}
textStyle={styles.fallbackText}
/>
);
}
const styles = StyleSheet.create((theme) => ({
fallbackText: {
fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE,
fontWeight: theme.fontWeight.semibold,
},
}));
@@ -0,0 +1,392 @@
import { useCallback, useMemo, type ReactElement, type ReactNode, type RefObject } from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
import { HostStatusDot } from "@/components/host-status-dot";
import { HostPicker } from "@/components/hosts/host-picker";
import type { LaunchTarget } from "@/new-workspace-launch/target";
import type { HostProjectListItem } from "@/projects/host-projects";
import type { HostProfile } from "@/types/host-connection";
import type { PickerItem } from "../../new-workspace-picker-item";
import {
FIELD_BADGE_HEIGHT,
FIELD_HEIGHT,
FieldTrigger,
type FieldTriggerVariant,
} from "./field-trigger";
import { branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
import { LaunchControl } from "./launch-control";
import { AddProjectOption, IsolationOption, ProjectOption, RefOption } from "./options";
type Isolation = "local" | "worktree";
type RenderOption = NonNullable<ComboboxProps["renderOption"]>;
interface PickerControl {
anchorRef: RefObject<View | null>;
open: () => void;
openState: boolean;
onOpenChange: (open: boolean) => void;
}
export interface NewWorkspaceFormProps {
isCompact: boolean;
isPending: boolean;
project: PickerControl & {
options: ComboboxOption[];
projectByOptionId: Map<string, HostProjectListItem>;
iconDataByProjectViewKey: Map<string, string | null>;
selectedProject: HostProjectListItem | null;
selectedOptionId: string;
triggerLabel: string;
selectedServerId: string;
/** COMPAT(workspaceMultiplicity): without it only worktree-capable projects are selectable. */
supportsWorkspaceMultiplicity: boolean;
onSelect: (id: string) => void;
onAddProject: () => void;
};
host: PickerControl & {
allHosts: HostProfile[];
selectedServerId: string;
onSelect: (id: string) => void;
onAddHost: () => void;
};
isolation: PickerControl & {
effectiveIsolation: Isolation;
canCreateWorktree: boolean;
onSelect: (isolation: Isolation) => void;
};
base: PickerControl & {
selectedSourceDirectory: string | null;
selectedItem: PickerItem | null;
triggerLabel: string;
options: ComboboxOption[];
itemById: Map<string, PickerItem>;
selectedOptionId: string;
onSelect: (id: string) => void;
setSearchQuery: (query: string) => void;
emptyText: string;
showRefPicker: boolean;
};
launch: {
serverId: string;
target: LaunchTarget;
onChange: (target: LaunchTarget) => void;
profiles: readonly TerminalProfile[];
disabled: boolean;
};
}
function hostOptionTestID(serverId: string): string {
return `new-workspace-host-picker-option-${serverId}`;
}
function isProjectSelectable(
project: HostProjectListItem,
supportsWorkspaceMultiplicity: boolean,
): boolean {
return (
supportsWorkspaceMultiplicity ||
project.hosts.some((host) => host.worktreeSupport !== "unsupported")
);
}
function useProjectPicker(
project: NewWorkspaceFormProps["project"],
isPending: boolean,
): { renderOption: RenderOption; footer: ReactNode } {
const { projectByOptionId, iconDataByProjectViewKey, selectedServerId } = project;
const { supportsWorkspaceMultiplicity, onAddProject } = project;
const renderOption = useCallback<RenderOption>(
(row) => {
const item = projectByOptionId.get(row.option.id);
return (
<ProjectOption
{...row}
project={item}
iconDataUri={item ? (iconDataByProjectViewKey.get(item.viewKey) ?? null) : null}
selectedServerId={selectedServerId}
disabled={isPending || !item || !isProjectSelectable(item, supportsWorkspaceMultiplicity)}
/>
);
},
[
iconDataByProjectViewKey,
isPending,
projectByOptionId,
selectedServerId,
supportsWorkspaceMultiplicity,
],
);
const footer = useMemo(() => <AddProjectOption onPress={onAddProject} />, [onAddProject]);
return { renderOption, footer };
}
function useIsolationPicker(
isolation: NewWorkspaceFormProps["isolation"],
isPending: boolean,
): { options: ComboboxOption[]; onSelect: (id: string) => void; renderOption: RenderOption } {
const { t } = useTranslation();
const { canCreateWorktree, onSelect, onOpenChange } = isolation;
// "New worktree" is omitted entirely (not disabled) when the project isn't a
// git checkout, since worktree isolation is impossible there.
const options = useMemo<ComboboxOption[]>(() => {
const local = { id: "local", label: t("newWorkspace.isolation.local") };
if (!canCreateWorktree) return [local];
return [local, { id: "worktree", label: t("newWorkspace.isolation.worktree") }];
}, [canCreateWorktree, t]);
const handleSelect = useCallback(
(id: string) => {
onSelect(id === "worktree" ? "worktree" : "local");
onOpenChange(false);
},
[onOpenChange, onSelect],
);
const renderOption = useCallback<RenderOption>(
(row) => <IsolationOption {...row} disabled={isPending} />,
[isPending],
);
return { options, onSelect: handleSelect, renderOption };
}
function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolean): RenderOption {
return useCallback<RenderOption>(
(row) => <RefOption {...row} item={itemById.get(row.option.id)} disabled={isPending} />,
[isPending, itemById],
);
}
/**
* The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack
* of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field
* that does not apply to the selected project is left out, and Host only shows with a second host.
*/
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
const { t } = useTranslation();
const { isCompact, isPending, project, host, isolation, base, launch } = props;
const variant: FieldTriggerVariant = isCompact ? "field" : "badge";
const projectPicker = useProjectPicker(project, isPending);
const isolationPicker = useIsolationPicker(isolation, isPending);
const renderRefOption = useRefRenderOption(base.itemById, isPending);
const showHost = host.allHosts.length > 1;
const selectedHostLabel =
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
const selectedProject = project.selectedProject;
const selectedProjectIconUri = selectedProject
? (project.iconDataByProjectViewKey.get(selectedProject.viewKey) ?? null)
: null;
const projectIcon = useMemo(
() =>
selectedProject ? (
<ProjectIcon
projectViewKey={selectedProject.viewKey}
projectName={selectedProject.projectName}
iconDataUri={selectedProjectIconUri}
/>
) : (
folderIcon
),
[selectedProject, selectedProjectIconUri],
);
const hostIcon = useMemo(
() => <HostStatusDot serverId={host.selectedServerId} />,
[host.selectedServerId],
);
const isWorktree = isolation.effectiveIsolation === "worktree";
const isolationValue = isWorktree
? t("newWorkspace.isolation.worktree")
: t("newWorkspace.isolation.local");
const refIcon = base.selectedItem?.kind === "github-pr" ? pullRequestIcon : branchIcon;
const projectField = (
<FieldTrigger
ref={project.anchorRef}
variant={variant}
label={t("newWorkspace.fields.project")}
accessibilityLabel="Workspace project"
tooltip={t("newWorkspace.tooltips.project")}
icon={projectIcon}
value={project.triggerLabel}
onPress={project.open}
disabled={isPending}
testID="new-workspace-project-picker-trigger"
/>
);
const hostField = showHost ? (
<HostPicker
hosts={host.allHosts}
value={host.selectedServerId}
onSelect={host.onSelect}
open={host.openState}
onOpenChange={host.onOpenChange}
anchorRef={host.anchorRef}
includeAddHost={isCompact}
onAddHost={host.onAddHost}
searchable={false}
title={t("newWorkspace.fields.host")}
desktopPlacement="bottom-start"
desktopMinWidth={200}
hostOptionTestID={hostOptionTestID}
>
<FieldTrigger
ref={host.anchorRef}
variant={variant}
label={t("newWorkspace.fields.host")}
tooltip={t("newWorkspace.tooltips.host")}
icon={hostIcon}
value={selectedHostLabel}
onPress={host.open}
disabled={isPending}
testID="host-picker-trigger"
/>
</HostPicker>
) : null;
const isolationField = isolation.canCreateWorktree ? (
<FieldTrigger
ref={isolation.anchorRef}
variant={variant}
label={t("newWorkspace.isolation.label")}
accessibilityLabel="Workspace isolation"
tooltip={t("newWorkspace.tooltips.isolation")}
icon={isWorktree ? branchIcon : folderIcon}
value={isolationValue}
onPress={isolation.open}
disabled={isPending}
testID="workspace-create-isolation-trigger"
/>
) : null;
const baseField = base.showRefPicker ? (
<FieldTrigger
ref={base.anchorRef}
variant={variant}
label={t("newWorkspace.fields.branch")}
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
tooltip={t("newWorkspace.tooltips.startingRef")}
icon={refIcon}
value={base.triggerLabel}
onPress={base.open}
disabled={isPending || !base.selectedSourceDirectory}
testID="new-workspace-ref-picker-trigger"
/>
) : null;
const launchField = (
<LaunchControl
variant={variant}
serverId={launch.serverId}
target={launch.target}
onChange={launch.onChange}
profiles={launch.profiles}
disabled={launch.disabled}
/>
);
const pickers = (
<>
<Combobox
options={project.options}
value={project.selectedOptionId}
onSelect={project.onSelect}
searchable
searchPlaceholder="Search projects"
title={t("newWorkspace.fields.project")}
open={project.openState}
onOpenChange={project.onOpenChange}
desktopPlacement="bottom-start"
desktopMinWidth={360}
anchorRef={project.anchorRef}
emptyText="No projects available."
renderOption={projectPicker.renderOption}
footer={projectPicker.footer}
/>
<Combobox
options={isolationPicker.options}
value={isolation.effectiveIsolation}
onSelect={isolationPicker.onSelect}
title={t("newWorkspace.isolation.label")}
open={isolation.openState}
onOpenChange={isolation.onOpenChange}
desktopPlacement="bottom-start"
anchorRef={isolation.anchorRef}
renderOption={isolationPicker.renderOption}
/>
<Combobox
options={base.options}
value={base.selectedOptionId}
onSelect={base.onSelect}
searchable
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
title={t("newWorkspace.refPicker.title")}
open={base.openState}
onOpenChange={base.onOpenChange}
onSearchQueryChange={base.setSearchQuery}
desktopPlacement="bottom-start"
anchorRef={base.anchorRef}
emptyText={base.emptyText}
renderOption={renderRefOption}
/>
</>
);
if (isCompact) {
return (
<View testID="new-workspace-ref-picker-row" style={styles.compactStack}>
{projectField}
{hostField}
{isolationField}
{baseField}
{launchField}
{/* Isolation and Base come and go with the project; their rows stay reserved so the
stack keeps one height and nothing above the composer jumps. */}
{isolationField ? null : <View style={styles.reservedField} pointerEvents="none" />}
{baseField ? null : <View style={styles.reservedField} pointerEvents="none" />}
{pickers}
</View>
);
}
return (
<View testID="new-workspace-ref-picker-row" style={styles.desktopRow} pointerEvents="box-none">
<View style={styles.desktopControl}>{projectField}</View>
{hostField ? <View style={styles.desktopControl}>{hostField}</View> : null}
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
<View style={styles.launchSpacer} pointerEvents="none" />
{launchField}
{pickers}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
compactStack: {
marginBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[2],
},
reservedField: {
height: FIELD_HEIGHT,
},
desktopRow: {
flexDirection: "row",
alignItems: "center",
minHeight: FIELD_BADGE_HEIGHT,
// Matches the gap between the composer and the Import session pill below it.
marginBottom: theme.spacing[4],
// The badge adds its own left padding; offset it so the project icon's left
// edge lands exactly on the "New workspace" title's left edge. The trailing
// inset mirrors it so the launch chip stops on the composer's inner content
// rather than running out to the composer's border.
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[2],
},
desktopControl: {
minWidth: 0,
flexShrink: 1,
},
// Pushes the launch control to the trailing edge of the desktop meta row.
launchSpacer: {
flex: 1,
},
}));
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useRouter } from "expo-router";
import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native";
import { MessageCircle, SquareTerminal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
@@ -30,14 +30,17 @@ import {
resolveLaunchProfile,
terminalLaunchTarget,
type LaunchTarget,
} from "./target";
} from "@/new-workspace-launch/target";
import {
FieldTriggerContent,
useFieldTriggerStyle,
type FieldTriggerVariant,
} from "./field-trigger";
const ThemedMessageCircle = withUnistyles(MessageCircle);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
const chatIcon = <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const blankTerminalIcon = <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
@@ -83,12 +86,7 @@ export interface LaunchControlProps {
onChange: (target: LaunchTarget) => void;
profiles: readonly TerminalProfile[];
disabled?: boolean;
/**
* The meta row's shared badge style. Passed in rather than redeclared so this
* trigger stays pixel-identical to the project, host, and branch triggers it
* sits beside; a local copy drifts the moment one of them changes.
*/
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
variant: FieldTriggerVariant;
}
function TriggerIcon({
@@ -108,8 +106,8 @@ function TriggerIcon({
}
/**
* The meta-row control choosing what New workspace submits to: the chat agent,
* or a terminal. One trigger shaped like every other chip on that row, opening
* The form control choosing what New workspace submits to: the chat agent,
* or a terminal. One trigger shaped like every other field trigger, opening
* a menu whose Chat and Terminal groups are what keep a terminal profile named
* "Codex" from reading as a chat Codex agent.
*/
@@ -119,13 +117,18 @@ export function LaunchControl({
onChange,
profiles,
disabled = false,
badgePressableStyle,
variant,
}: LaunchControlProps) {
const { t } = useTranslation();
const router = useRouter();
const [open, setOpen] = useState(false);
const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]);
const triggerStyle = useFieldTriggerStyle(variant, disabled);
const triggerIcon = useMemo(
() => <TriggerIcon target={target} profile={selectedProfile} />,
[selectedProfile, target],
);
// A profile's own name and logo say nothing about where it runs: a row
// reading "(openai logo) Codex" is indistinguishable from a chat Codex agent.
@@ -149,32 +152,26 @@ export function LaunchControl({
return (
<DropdownMenu open={open} onOpenChange={setOpen} compactMode="sheet">
{/* The trigger is the Pressable itself, styled by the meta row's shared
badge style, so it lands pixel-identical to the project, host, and
branch chips it sits beside. */}
{/* MenuTrigger is not a forwardRef component and pins its own ref, so the
tooltip anchors to a wrapping View instead. Suppressed while the menu
is open so the two surfaces never overlap. */}
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
<Tooltip delayDuration={300} enabledOnDesktop={!open && variant === "badge"}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
testID="new-workspace-launch-trigger"
disabled={disabled}
style={badgePressableStyle}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("newWorkspace.tooltips.launch")}
>
<View style={styles.iconBox}>
<TriggerIcon target={target} profile={selectedProfile} />
</View>
<Text style={styles.label} numberOfLines={1}>
{triggerLabel}
{selectedProfile ? (
<Text style={styles.profileName}> {selectedProfile.name}</Text>
) : null}
</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColorMapping} />
<FieldTriggerContent
variant={variant}
label={t("newWorkspace.launch.title")}
icon={triggerIcon}
value={triggerLabel}
detail={selectedProfile?.name}
/>
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
@@ -232,27 +229,10 @@ export function LaunchControl({
}
const styles = StyleSheet.create((theme) => ({
iconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
label: {
minWidth: 0,
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
profileName: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundExtraMuted,
},
command: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
@@ -0,0 +1,166 @@
import { useMemo, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { getHostProjectSourceDirectory, type HostProjectListItem } from "@/projects/host-projects";
import { pickerItemLabel, type PickerItem } from "../../new-workspace-picker-item";
import { addProjectIcon, branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
interface OptionRowState {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}
function IconBox({ children }: { children: ReactElement }): ReactElement {
return <View style={styles.iconBox}>{children}</View>;
}
export function ProjectOption({
option,
selected,
active,
onPress,
project,
iconDataUri,
selectedServerId,
disabled,
}: OptionRowState & {
project: HostProjectListItem | undefined;
iconDataUri: string | null;
selectedServerId: string;
disabled: boolean;
}): ReactElement {
const leadingSlot = useMemo(
() =>
project ? (
<IconBox>
<ProjectIcon
projectViewKey={project.viewKey}
projectName={project.projectName}
iconDataUri={iconDataUri}
/>
</IconBox>
) : undefined,
[iconDataUri, project],
);
if (!project) return <View key={option.id} />;
return (
<ComboboxItem
testID={`new-workspace-project-picker-option-${project.viewKey}`}
label={project.projectName}
description={
getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir
}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
export function AddProjectOption({ onPress }: { onPress: () => void }): ReactElement {
const { t } = useTranslation();
const openProjectKeys = useShortcutKeys("new-agent");
const shortcut = useMemo(
() => (openProjectKeys ? <Shortcut chord={openProjectKeys} /> : null),
[openProjectKeys],
);
return (
<ComboboxItem
testID="new-workspace-project-picker-add-project"
label={t("sidebar.actions.addProject")}
onPress={onPress}
leadingSlot={addProjectIcon}
trailingSlot={shortcut}
/>
);
}
const worktreeLeading = <IconBox>{branchIcon}</IconBox>;
const localLeading = <IconBox>{folderIcon}</IconBox>;
export function IsolationOption({
option,
selected,
active,
onPress,
disabled,
}: OptionRowState & { disabled: boolean }): ReactElement {
return (
<ComboboxItem
testID={`workspace-create-isolation-${option.id}`}
label={option.label}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={option.id === "worktree" ? worktreeLeading : localLeading}
/>
);
}
const branchLeading = <IconBox>{branchIcon}</IconBox>;
const pullRequestLeading = <IconBox>{pullRequestIcon}</IconBox>;
export function RefOption({
option,
selected,
active,
onPress,
item,
disabled,
}: OptionRowState & { item: PickerItem | undefined; disabled: boolean }): ReactElement {
const { t } = useTranslation();
const divergenceLabel = item?.kind === "branch" ? item.divergenceLabel : undefined;
const trailingSlot = useMemo(
() =>
divergenceLabel ? <Text style={styles.divergenceLabel}>{divergenceLabel}</Text> : undefined,
[divergenceLabel],
);
if (!item) return <View key={option.id} />;
const isBranch = item.kind === "branch";
return (
<ComboboxItem
testID={
isBranch
? `new-workspace-ref-picker-branch-${item.name}`
: `new-workspace-ref-picker-pr-${item.item.number}`
}
label={pickerItemLabel(item)}
description={
!isBranch && item.item.baseRefName
? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName })
: undefined
}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={isBranch ? branchLeading : pullRequestLeading}
trailingSlot={trailingSlot}
accessibilityLabel={isBranch ? item.accessibilityLabel : undefined}
/>
);
}
const styles = StyleSheet.create((theme) => ({
iconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
divergenceLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontVariant: ["tabular-nums"],
},
}));
@@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
/**
* The new workspace screen's way into Import session: a ghost button at the top on compact
* The new workspace screen's way into Import session: a ghost button in the header on compact
* layouts, an outlined pill under the composer otherwise. Both are muted until hovered.
*/
export function ImportSessionButton({
@@ -17,7 +17,7 @@ export function ImportSessionButton({
}) {
const { t } = useTranslation();
return (
<View style={compact ? styles.compact : styles.wide}>
<View style={compact ? null : styles.wide}>
<Button
// Ghost for its muted text that turns foreground on hover; the pill adds the outline.
variant="ghost"
@@ -34,9 +34,6 @@ export function ImportSessionButton({
}
const styles = StyleSheet.create((theme) => ({
compact: {
alignItems: "flex-start",
},
// The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's
// border lines up with the composer's border.
wide: {
@@ -182,6 +182,7 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
status: "done",
currentBranch: "main",
changeRequestNumber: null,
statusEnteredAt: null,
...overrides,
};
}
@@ -1,4 +1,9 @@
import { AssistantFileLinkResolverProvider, type InlinePathTarget } from "@/assistant-file-links";
import {
AssistantFileLinkResolverProvider,
createInlinePathOpener,
type InlinePathTarget,
} from "@/assistant-file-links";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import {
WorkspaceComposerEditingSessionsProvider,
useComposerTabFullscreen,
@@ -203,6 +208,7 @@ import { PluginHeaderButtons } from "@/plugins";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
@@ -3658,9 +3664,42 @@ function WorkspaceScreenContent({
</View>
);
const openMarkdownFile = useStableEvent((target: InlinePathTarget) => {
handleOpenPreferredAssistantFile({ location: target, parentTabId: activeTabId });
const { revealDirectory } = useFileExplorerActions({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
workspaceRoot: workspaceDirectory,
});
const openMarkdownFile = useStableEvent((location: WorkspaceFileLocation) => {
handleOpenPreferredAssistantFile({ location, parentTabId: activeTabId });
});
const openMarkdownDirectory = useStableEvent((directoryPath: string) => {
void revealDirectory(directoryPath);
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: persistenceKey,
checkout: activeExplorerCheckout,
view: "files",
});
});
const markdownPathOpener = useMemo(
() =>
createInlinePathOpener({
workspaceRoot: workspaceDirectory ?? "",
client,
openFile: openMarkdownFile,
openDirectory: openMarkdownDirectory,
}),
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
);
useEffect(() => {
if (!isRouteFocused) markdownPathOpener.cancel();
return () => markdownPathOpener.cancel();
}, [markdownPathOpener, isRouteFocused, activeTabId]);
const openMarkdownPath = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (isRouteFocused) void markdownPathOpener.open(target, disposition);
},
);
const renderedWorkspaceScreen = (
<RenderProfile id="WorkspaceScreenContent">
@@ -3705,7 +3744,7 @@ function WorkspaceScreenContent({
client={client}
serverId={normalizedServerId}
workspaceRoot={workspaceDirectory ?? undefined}
onOpenWorkspaceFile={openMarkdownFile}
onOpenWorkspaceFile={openMarkdownPath}
toast={toast}
>
{renderedWorkspaceScreen}
+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. */
+44 -10
View File
@@ -13,6 +13,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";
@@ -219,14 +220,16 @@ 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}
@@ -247,6 +250,7 @@ function SubagentsTrackRow({
presentation,
row.kind,
row.id,
serverId,
],
);
@@ -262,6 +266,21 @@ 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,
@@ -360,9 +379,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,
@@ -41,6 +41,93 @@ function rowWithText(
}
describe("native terminal selection", () => {
it.each([
{ startCol: 1, endCol: 8, expected: "한글 문장" },
{ startCol: 3, endCol: 8, expected: "글 문장" },
{ startCol: 1, endCol: 1, expected: "한" },
{ startCol: 0, endCol: 0, expected: "한" },
{ startCol: 0, endCol: 1, expected: "한" },
{ startCol: 4, endCol: 6, expected: " 문" },
])(
"copies complete wide characters for selected columns $startCol through $endCol",
async ({ startCol, endCol, expected }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
const copied: string[] = [];
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
const selection = createTerminalSelectionModel();
selection.begin({
coordinate: { row: bounds.cursorRow, col: endCol },
bounds,
});
selection.update({
coordinate: { row: bounds.cursorRow, col: startCol },
bounds,
});
await copyTerminalSelection({
terminal,
selection: selection.getSnapshot().range,
clipboard: {
writeText: async (text) => {
copied.push(text);
},
},
});
expect(copied).toEqual([expected]);
} finally {
terminal.dispose();
}
},
);
it("copies Korean output without adding spaces for continuation cells", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
const copied: string[] = [];
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
await copyTerminalSelection({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
clipboard: {
writeText: async (text) => {
copied.push(text);
},
},
});
expect(copied).toEqual(["한글 문장"]);
} finally {
terminal.dispose();
}
});
it("copies Korean text and real spaces without wide-character continuation spaces", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write("한글 문장 | 성공");
const bounds = terminal.getBufferBounds();
expect(
extractTerminalSelectedText({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
}),
).toBe("한글 문장 | 성공");
} finally {
terminal.dispose();
}
});
it("expands a long-press coordinate to the whole terminal word", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 });
await terminal.write("before PASEO_TARGET after\r\n");

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