Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 4c6f285930 test(worktrees): await repository cleanup after remote timeouts 2026-10-12 06:20:51 +02:00
paseo-bot[bot] 23fb1cc85a test(schedules): use the canonical Git root for worktree fixtures 2026-10-12 06:20:51 +02:00
paseo-bot[bot]and见安 fd1551d46e fix(cli): reject fractional integer options before rounding
Validate decimal integrality before Number conversion can hide a fraction. Keep exact integer scientific notation, signed capture indices, and schedule limit updates working.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
2026-10-12 06:20:51 +02:00
见安 4d67f08922 test(cli): verify integer options against a real daemon 2026-10-12 06:20:51 +02:00
见安 35edd688b5 fix(terminal): validate complete capture line numbers 2026-10-12 06:20:22 +02:00
见安 eed6478a7d fix(schedule): validate complete max-runs values 2026-10-12 06:20:22 +02:00
paseo-bot[bot]and见安 9b92b2e25c Preserve base worktree paths when merging (#6668)
* fix(git): preserve the base worktree path when merging

* test(git): cover merging into whitespace worktree paths

* test(git): follow the server object parameter convention

* test(git): keep merge fixture line endings deterministic

Disable automatic CRLF checkout in the fixture repository so the exact-content assertion works with Windows Git defaults.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 06:00:59 +02:00
Elowen a310444c67 fix: save workspace files with long valid names (#6658)
* fix(file-explorer): keep atomic save temporary names short

* test(file-explorer): cover saving long names through daemon RPC
2026-10-12 04:58:00 +02:00
paseo-bot[bot]and见安 c364b16d40 Report canceled child tasks and wait for replacement tasks to finish (#6660)
* fix(server): report canceled child turns to their callers

* fix(server): wait for replacement child tasks before notifying callers

A displaced turn emits cancellation while its replacement is still running. Notify on terminal events only once the child becomes idle, so Stop and Reload report cancellation while replacement prompts produce one eventual outcome.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

* test(server): remove debug output from cancellation regressions

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 04:00:12 +02:00
paseo-bot[bot] dc7bc8db12 fix(app): keep typechecking after Expo generates environment types (#6656)
Expo declares NODE_ENV readonly after web startup. Preserve the early test environment setup with Object.assign and exercise the real configuration with Expo types in a failing-first compiler regression.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 02:49:09 +02:00
paseo-bot[bot] 40f0393064 Record shortcuts using the keyboard layout character (#6652)
* fix(app): record shortcuts using the keyboard layout character

Shortcut capture stored the QWERTY position while dispatch matched the logical character, so a Dvorak B became Cmd+N. Use the existing logical key mappings for capture and preserve code fallback for named and rewritten Option keys.

* fix(app): keep named shortcut keys on their supported codes

Character capture must not advertise unsupported named keys. Restrict the logical lookup to printable mappings so keypad Enter keeps its existing unsupported behavior.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 01:58:11 +02:00
paseo-bot[bot] 166aabd57b Keep subprocess regression tests independent of terminal colors (#6648)
* test(server): isolate subprocess regressions from terminal colors

Inherited NO_COLOR and FORCE_COLOR make Node emit an unrelated warning. It contaminates argv-fixture stderr and fails the closed-output supervisor fixture before its pipe handlers are installed. Clear both settings only in these fixture subprocesses, preserving their output and lifecycle assertions.

* test(server): exercise conflicting colors regardless of parent environment

Run the closed-output and spaced-executable regressions with explicit conflicting color inputs as well as the inherited environment, so removing fixture isolation fails on ordinary CI environments too.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 00:47:28 +02:00
b852ae920b fix(desktop): show the Paseo window icon on Linux (#6645)
Packaged windows read resources/icon.png, but Linux packages only
installed desktop-entry icons. Include the runtime PNG and verify its
bytes in the existing packaged artifact smoke test.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Pantic <panta@talentkit.io>
Co-authored-by: Max Höhl <43786929+whme@users.noreply.github.com>
2026-10-11 23:57:52 +02:00
paseo-bot[bot]andX b3693b79d1 Find hidden agent plan files in workspace suggestions (#6641)
* fix(server): find hidden agent plan files in workspace suggestions

Workspace autocomplete skipped the editor's plan directory while traversing
other known agent directories. Include it in the existing discovery policy
and cover basename and partial-path queries through the daemon client.

Co-authored-by: X <X@16x.org>

* test(server): give plan suggestion queries explicit expectations

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: X <X@16x.org>
2026-10-11 21:54:46 +02:00
0458988825 Connect the self-hosted web app through standard-port proxies (#6638)
* fix(server): give the web UI connection hint an explicit port (#1)

injectConnectionHint built `listen` straight from the Host header, which
carries no port when the client used the scheme's default — exactly what a
browser sends to a proxy terminating TLS on 443. The app parses `listen` with
parseHostPort, which requires an explicit port, so connectionFromListen
returned null and bootstrapInitialConnectionHint bailed before probing,
silently. Boot then fell through to the localhost:6767 fallback, which is the
viewer's own machine rather than the daemon, and the app landed on the welcome
screen with no host at all.

Reaching the daemon directly never hit this: http://host:6767 puts the port in
the Host header. It only appears behind a reverse proxy on 443 or 80, such as a
Tailscale Ingress, and only in a browser profile with no host already stored.

Spell the scheme default out when the header omits it, leaving the IPv6 bracket
form intact.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(server): compute the web bootstrap port before its connection hint

Keep the default-port fix for reverse proxy bootstrap within the repository branching style.

Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>

---------

Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 20:51:51 +02:00
paseo-bot[bot] 4bd31e1a2e test(cli): accept Codex catalogs without branded model IDs (#6633)
The provider regression test rejected valid GPT catalogs solely because no ID contained codex. Keep the structural output checks without requiring that upstream branding substring.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 19:47:10 +02:00
paseo-bot[bot]andOkan YILDIRIM 1939ee79ed Accept daemon settings in the published editor schema (#6621)
* fix(config): accept daemon settings in the published editor schema

The website schema omitted accepted configuration fields, so editors
rejected valid browser, profile, plugin and metadata settings. Regenerate
from the daemon schema and guard the published artifact against drift.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>

* fix(config): preserve proxy help when regenerating the schema

Keep the existing editor descriptions in the source schema so regeneration retains them. The new regression fails when the proxy descriptions disappear.

* test(usage): create SQLite store fixtures in one transaction

The Windows plugin suite exceeded its five-second test deadline while creating and exercising the first OMP store fixture. Commit the fixture as a unit rather than flushing each inserted row separately; preserve the read-only discovery assertions.

* test(config): show structured schema validation errors

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
2026-10-11 18:50:03 +02:00
paseo-bot[bot]andBudi 8d9e7d3ad3 Confirm remote permission approvals before reporting success (#6607)
* fix(cli): await permission resolution before closing transport

* test(cli): verify permission confirmation over real connections

Exercise permission commands through a real WebSocket protocol peer, keeping responses pending until explicit resolution. This catches success before acknowledgement without replacing the client connector or process output. Retain the contributor permission fix and verify both commands, batches, and unconfirmed responses.

Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>

* test(cli): catch permission batches completing after one reply

Hold the second acknowledgement until a WebSocket round trip proves the first reply reached the client. A first-reply-only mutation must fail both batch tests.

* test(cli): inject permission clients into command regression tests

* test(cli): use timer helper for permission continuations

* style(cli): format permission regression tests with oxfmt

---------

Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 18:03:43 +02:00
paseo-bot[bot]and见安 114389383d Keep files starting with two dots usable (#6604)
* fix(file-explorer): distinguish parent traversal from dot-prefixed names

* test(file-explorer): cover dot-prefixed paths through daemon RPC

* test(plugins): allow slower session readiness in provider fixtures

The event helper exhausted 200 iterations before a valid delayed session
became ready. Use an elapsed-time deadline while preserving real timers
for watchdog tests, and retain a delayed-start regression with launch
flag assertions.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 16:51:00 +02:00
Elowen 804335bc00 fix(files): rename untracked bracket paths without matching siblings (#6573)
* fix(files): use literal paths when checking rename tracking

* fix(files): preserve Git activity labels for rename lookups

* fix(files): clear inherited literal mode for rename lookups

* test(files): cover tracked renames with inherited literal mode
2026-10-11 13:53:25 +02:00
Jiawen Geng e2fd5a9c57 fix(app): keep markdown text that only looks like HTML verbatim (#6370)
The html-ish pass feeds markdown to htmlparser2 so it can render
<details>, <img> and friends. htmlparser2 reads any `<` followed by a
letter as a tag and collects attributes up to the next `>`. A cell such
as `1<ratio<2` in a table therefore swallowed the rows after it, and the
re-serialized "tag" dropped the newlines and pipes that made them rows.
The same path broke autolinks (`<https://x/y>` became `<https: x y>`)
and cut text after a trailing `a<b`.

Only hand htmlparser2 what CommonMark reads as raw HTML: open tags,
closing tags and comments. Every other `<` stays text.

Unknown tags are rendered back as text, but they were rebuilt from the
parser's lowercased name and attributes, so `As<Integer>()` showed as
`As<integer>()`. Echo the source slice instead.
2026-10-11 13:20:37 +02:00
paseo-bot[bot]andIvan Malison b412374f0c Run React tests when the shell uses NODE_ENV=production (#6572)
* fix(tests): run React tests from production shells

Vitest preserves an inherited NODE_ENV, selecting React without its test APIs. Pin the environment before dependency resolution and worker startup in both test entry points.

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

* test(app): expect zero edits for a pure staged rename

The rename patch correction on main preserves unchanged content. Keep the discard journey intact and update its obsolete added-line assertion.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-10-11 11:52:50 +02:00
paseo-bot[bot]andYu Han a3f75aa9e4 Show removed SQL comments in Git diffs (#6569)
Hunk content resembling patch headers was discarded before line parsing. Metadata is already ignored before the first hunk, so preserve every added and removed line once a hunk starts.

Closes #6564

Co-authored-by: Yu Han <73627805+Mar-garet@users.noreply.github.com>
2026-10-11 11:03:15 +02:00
paseo-bot[bot]and见安 46b1284177 Release files when HTTP downloads are canceled (#6567)
* fix(server): close canceled HTTP download streams

* test(server): verify canceled downloads release real file descriptors

Exercise the reported HTTP cancellation path without replacing shared Node methods, and include download regressions in the standard integration suite.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

* test(server): detect canceled download leaks through symlinked paths

Compare descriptor targets with the resolved file path and exercise a symlinked download directory so the regression cannot pass by missing the open file.

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 09:57:29 +02:00
paseo-bot[bot]and见安 a5dba3c992 Show checkout patch lines with external Git diff commands configured (#6562)
* fix(server): parse Git patches without external diff commands

* test(server): keep diff command checks aligned with parsed patches

The external diff regression fix intentionally disables helper commands.
Keep the existing large-file batching assertions aligned with that command
while preserving their patch, omission, and concurrency checks.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 08:58:07 +02:00
paseo-bot[bot] dd0350bc2b Show actual edits when a file is renamed in Changes (#6559)
* fix(server): show actual edits when a file is renamed

Path-limited patch requests discarded the detected rename source, so Git reported the destination as a new file. Include both literal paths and select the destination patch to preserve the rename and avoid duplicating changed copy sources.

* fix(server): preserve small copy patches beside oversized sources

Filter rename and copy output before enforcing the bounded request budget, so a modified source cannot hide its small copy patch.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 07:55:09 +02:00
paseo-bot[bot] cfb18f16d4 fix(app): find starting refs by change request descriptions (#6558)
Preserve returned change request bodies as search-only option text so client filtering keeps description matches. Keep title and ID matches ranked ahead of secondary text.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 06:53:37 +02:00
paseo-bot[bot] 7f9b65d464 test(server): keep commit fixture authors independent of Git config (#6552)
Command-scoped user.name overrides the fixture repository configuration and breaks its author assertion. Supply the fixture author when creating commits and exercise the override with real Git.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 05:46:39 +02:00
paseo-bot[bot] 7e66657bf0 fix(agent): preserve reasoning order in activity transcripts (#6546)
Format projected text entries in sequence instead of grouping replies ahead of their preceding reasoning. Timeline projection already owns fragment merging.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 05:00:02 +02:00
paseo-bot[bot] 446749102e test(server): make commit signing checks independent of global format (#6540)
The failing signer uses gpg.program, which Git bypasses when a global SSH signing format is inherited. Select OpenPGP in the commit and squash-merge test repositories so both checks exercise their configured failing signer.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 03:46:59 +02:00
paseo-bot[bot] e61009d5fb fix(server): display diffs with custom Git presentation settings (#6536)
Request uncolored patches with explicit a/ and b/ prefixes before parsing. Mnemonic paths do not match checkout entries, and forced ANSI colors hide patch headers and hunks.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 03:00:33 +02:00
ee0d901efb Run each scheduled slot once without skipping the next occurrence (#6532)
* fix(server): claim scheduled slots atomically

* fix(server): skip elapsed schedule slots

* test(server): isolate overlapping tick runner

* test(schedule): reproduce duplicate cron dispatch across overlapping ticks

* fix(schedule): preserve final slots and recovery after failed outcome writes

Historical running records are not proof of a live execution after an outcome write fails. Keep live-run exclusion in the existing in-memory guard while the persisted cursor prevents duplicate slot claims. Final allowed runs do not need a future cron occurrence; recover interrupted final runs and preserve cadence if their limit changes during execution.

---------

Co-authored-by: Keith Kowal (paseo-dev) <keithpk+paseo-dev@kowal.me>
Co-authored-by: keithpk <keith@kowal.me>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 02:19:11 +02:00
paseo-bot[bot]andLorenzo Fiore d5513472ed Keep words intact in followed agent logs (#6528)
* fix(cli): keep streamed message fragments together in logs --follow

`paseo logs` without `-f` fetches the timeline projected, so
`projectTimelineRows` merges consecutive assistant and reasoning fragments by
exact concatenation before the transcript is rendered. Follow mode never got
there: it called `formatAgentActivityTranscript([item])` per arriving event, so
every fragment was projected alone and printed on its own line. Words broke at
fragment boundaries, a reader could not tell an author newline from a transport
one, and `[Thought]` was reprinted for each reasoning chunk.

Follow mode now keeps a fragment chain open using the same merge rule the
projection uses (same kind, same `turnId`, matching `messageId` when the event
carries one) and writes the text as it arrives instead of one line per event.
Only trailing whitespace is held back, so a message still ends trimmed the way
the one-shot transcript ends it, and the chain closes when another kind of item
arrives or the follow stops.

* test(cli): place daemon follow regression in its owning suite

* fix(cli): preserve filtered log boundaries and run the follow regression in CI

* test(cli): wait for a completed log line before terminating follow

---------

Co-authored-by: Lorenzo Fiore <fiorelorenzo.fl@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 00:55:14 +02:00
paseo-bot[bot] 1228c01a10 Find GitLab merge requests and issues by number (#6525)
* fix(gitlab): find merge requests and issues by number

GitLab text search matches titles and descriptions but not item numbers. Use repository-scoped IID filtering for numeric queries, keeping open-only results and the existing text search behavior.

* fix(gitlab): retain text matches in number searches

Supplement title and description search with the open-item number lookup, rather than replacing it. Prioritize exact numbers, deduplicate results, and preserve each list limit.

* refactor(gitlab): name list helper arguments

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 00:03:10 +02:00
563c014dc1 Open assistant links to non-ASCII filenames (#6520)
* fix(app): decode percent-encoded assistant file links

markdown-it percent-encodes non-ASCII and space characters in link
hrefs, so a link like [x](docs/reports/报告.md) arrives as
docs/reports/%E6%8A%A5%E5%91%8A.md. The file:// and bare absolute-path
branches already run safeDecodeURIComponent, but the workspace-relative,
path:line, Windows, and home-relative branches did not, so clicking the
link tried to open the still-encoded name and failed with ENOENT.

Decode the path portion before the relative-path heuristics and in the
inline absolute/Windows branches so all href forms behave the same.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(app): decode hrefs only, never raw inline-code paths

Raw inline-code and linkified tokens reach the same parser as markdown
hrefs. Decoding unconditionally would send a real file named
my%20file.md to my file.md. Gate every decode on AssistantHrefParseOptions.decodeHref — set by the resolver only when the token
came from source.href — and extract parseWindowsFileLink to keep the
entry point under the complexity limit. Adds parse-level and
resolver-level regression tests for literal %20 filenames.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(app): keep raw absolute paths out of URL parsing

new URL() percent-encodes non-ASCII, so a raw inline-code path like
/tmp/开户.md became /tmp/%E5%BC%80%E6%88%B7.md when decodeHref was
false. Hrefs still go through URL parsing (normalizes dot segments
before decode); raw paths split the # fragment manually instead.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(app): keep encoded and literal file links distinct

Preserve inline-code provenance for Windows paths and key cached file lookups by their resolved query, path, and line range. Reproduce the reported non-ASCII Markdown link through the renderer and filesystem, with browser coverage for clicking it.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>

Co-authored-by: dengjiawen <dengjiawen@hortorgames.com>

* test(app): assert file content after opening the markdown link

Co-authored-by: sunshinehomeyu-blip <267551119+sunshinehomeyu-blip@users.noreply.github.com>

* fix(app): preserve encoded line markers in filenames

---------

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: dengjiawen <dengjiawen@hortorgames.com>
Co-authored-by: sunshinehomeyu-blip <267551119+sunshinehomeyu-blip@users.noreply.github.com>
2026-10-10 22:00:00 +02:00
Mohamed Boudra 01f9bc8291 Modernize compact headers and sidebar affordances (#6431)
* feat(app): modernize compact headers and sidebar affordances

Use a shared island header with quick tab creation and keep sheet footers inside their visible layout. Opt sidebar rows into touch sizing without changing global appearance settings.

* test(app): match darker light-theme workspace selection

* test(app): align plugin journeys with compact header
2026-10-10 20:53:09 +01:00
paseo-bot[bot] d0728cd1ed fix(opencode): preserve tool validation errors in v2 (#6519)
Failed bridge requests discarded the daemon error body before OpenCode could show the validation explanation to the agent. Preserve that message and status while retaining the HTTP fallback.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-10 21:01:07 +02:00
paseo-bot[bot]and见安 0ba4d2526e Honor timestamps when reading and following agent logs (#6514)
* fix(cli): honor --since in agent logs

* test(cli): verify logs cutoff in the daemon-owned suite

Keep the real CLI and WebSocket regression while placing daemon fixtures
in the server suite, as the CI ownership contract requires. Invoke the
source CLI so the test runs with the server suite's dependency build.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

* test(server): use a portable loader URL for the logs regression

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-10 19:48:42 +02:00
paseo-bot[bot]andJason 7868f5caf6 Keep email addresses intact when sending with Enter (#6471)
* fix: prevent file mention autocomplete from triggering on emails

Skip @ when preceded by a word character or +, and reject angle
brackets in mention queries so email addresses no longer open the
file autocomplete or steal Enter.

Closes #1364

* test(app): verify emails send intact without file completion

The word-character and plus guard tests previously passed because their queries included trailing spaces. Exercise each guard with the cursor at the end of the email, and cover Enter in the browser alongside normal file completion.

Co-authored-by: Jason <mildreins@gmail.com>

---------

Co-authored-by: Jason <mildreins@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-10 17:00:08 +02:00
paseo-bot[bot]andJason 0147edaaac Restore repository selection for CLI worktree commands (#6467)
* fix(cli): pass cwd to worktree ls and archive

worktree ls/archive called getPaseoWorktreeList({}) which fails because
the daemon requires cwd or repoRoot. Default to process.cwd(), accept
optional --cwd, and cover both with unit and CLI help tests.

Closes #2010

* test(cli): reproduce worktree listing without repository scope

* fix(cli): resolve local worktree paths from the calling directory

Local instance paths belong to the CLI working directory. Explicit endpoint paths remain daemon-relative, matching the project command convention. Cover both through the real daemon.

* refactor(cli): make repository directory selection explicit

---------

Co-authored-by: Jason <mildreins@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-10 15:53:10 +02:00
paseo-bot[bot]and2takumax-chura bd78e5efa4 Show committed changes for existing-branch worktrees (#6462)
* fix: resolve real base branch for worktrees checked out from an existing branch

When a worktree is created by checking out an already-existing branch,
the stored baseRefName was set to the branch's own name instead of the
repository's default branch. The diff panel then diffs the branch
against itself, always showing no changes.

Fixes #1873

* fix: fall back to the current branch when no default branch is found

Addresses Greptile review feedback on #1874: resolveRepositoryDefaultBranch
returns null for local-only repos with no origin/HEAD and no local
main/master (e.g. a repo whose trunk is "develop"), which previously
fell back to storing the checked-out branch as its own base — the
exact self-diff bug this PR fixes. Fall back to whichever branch is
currently checked out in the main repo instead.

* fix(server): preserve existing-branch diffs through workspace restore

Carry the resolved comparison base from the worktree creator into the workspace record. Resolve it for occupied branches too, so a fresh suffixed checkout still displays the feature's committed changes.

Co-authored-by: 2takumax-chura <takuma.okayama@churadata.okinawa>

* fix(server): skip deleted default refs when creating worktrees

---------

Co-authored-by: 2takumax-chura <takuma.okayama@churadata.okinawa>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-10 15:18:05 +02:00
e81d5d02b1 Keep terminal CJK text unchanged after returning to a workspace (#6452)
* fix(app): restore terminal surface after tab hide so CJK cells redraw

Retained panels use display:none. Coming back remasured without treating
that as a surface restore, so Hangul kept 2-cell slots while glyphs painted
at ~1 cell (extra gap). Reload WebGL if the context was lost and force a
refresh when the host grows from zero.

Closes #3434

* fix(server): keep wide-char continuation cells out of terminal snapshots

Wide chars (CJK) occupy two xterm cells, and the second cell reports no
chars. The snapshot capture collapsed that empty continuation to " ", so
every restored Hangul syllable took three columns instead of two. The
injected space was real: selecting and copying the restored row carried
it into the clipboard.

Capture the continuation as "" and have renderTerminalSnapshotToAnsi
skip it. No schema change; snapshots from older daemons still replay
verbatim.

* fix(app): restore retained terminals on presentation

Co-authored-by: seob717 <dev.seob717@gmail.com>

* fix(terminal): limit restoration changes to confirmed CJK corruption

Keep the independently reproduced snapshot fix. Surface recovery behavior remains unverified and belongs with the broader viewport report. Dispose the replay terminal after the regression test.

---------

Co-authored-by: Jason <mildreins@gmail.com>
Co-authored-by: seob717 <dev.seob717@gmail.com>
Co-authored-by: liujin0506 <liujin0506@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-10 13:27:04 +02:00
148 changed files with 7645 additions and 2316 deletions
-1
View File
@@ -252,7 +252,6 @@
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true,
@@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
@@ -0,0 +1,96 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
const labels = {
long: {
title: "workspace-with-a-very-long-release-and-investigation-name",
project: "project-with-a-very-long-customer-integration-name",
},
short: { title: "main", project: "Paseo" },
};
async function openNamedWorkspace(page: Page, names: typeof labels.long) {
const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
try {
await workspace.client.renameProject(workspace.projectId, names.project);
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Header layout check",
modeId: "load-test",
model: "e2e-fast-stream",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
await expect(
page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
).toHaveCount(1);
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
const title = page.getByTestId("workspace-header-title");
const project = page.getByTestId("workspace-header-subtitle");
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
const trigger = page.getByRole("button", { name: /Switch tabs/ });
const measure = async () => ({
title: await title.boundingBox(),
project: await project.boundingBox(),
badge: await badge.boundingBox(),
icon: await badge.locator("svg").boundingBox(),
trigger: await trigger.boundingBox(),
titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
});
await page.screenshot({ path: info.outputPath("header.png") });
await expect(async () => {
const bounds = await measure();
expect(bounds.title).not.toBeNull();
expect(bounds.project).not.toBeNull();
expect(bounds.badge).not.toBeNull();
expect(bounds.icon).not.toBeNull();
expect(bounds.trigger).not.toBeNull();
// Approved geometry: 12px outside margin, 46px leading island, 8px title gap.
expect(bounds.title!.x).toBe(66);
expect(bounds.project!.x).toBe(bounds.title!.x);
expect(bounds.trigger!.width).toBe(32);
expect(bounds.trigger!.height).toBe(32);
// The action frame starts 7px inside its island; preserve the 8px gap before it.
const contentRight = bounds.trigger!.x - 15;
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
expect(box.x + box.width).toBeLessThanOrEqual(contentRight + 1);
}
expect(bounds.project!.width).toBeGreaterThan(20);
expect(bounds.icon!.width).toBe(12);
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
expect(bounds.titleTruncated).toBe(long);
expect(bounds.projectTruncated).toBe(long);
}).toPass({ timeout: 5_000 });
await info.attach("header-bounds", {
body: JSON.stringify(await measure(), null, 2),
contentType: "application/json",
});
}
for (const width of [320, 390]) {
for (const kind of ["long", "short"] as const) {
test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 844 });
const workspace = await openNamedWorkspace(page, labels[kind]);
try {
await expectHeaderContentsFit(page, info, kind === "long");
} finally {
await workspace.cleanup();
}
});
}
}
@@ -0,0 +1,55 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// The popover has no semantic role; its rows are React Native Pressables.
function fileSuggestions(page: Page) {
return page.getByTestId("composer-autocomplete-popover");
}
async function selectReadmeMention(page: Page): Promise<void> {
await fillComposerDraft(page, "@README");
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0);
await composerLocator(page).press("Enter");
await expectComposerDraft(page, '"README.md"');
}
async function sendEmailIntact(page: Page, email: string): Promise<void> {
await fillComposerDraft(page, email);
await expect(fileSuggestions(page)).not.toBeVisible();
await composerLocator(page).press("Enter");
await expectComposerDraft(page, "");
await expect(page.getByText(email, { exact: true })).toBeVisible();
}
for (const email of [
"user <12345+user@noreply.example.com>",
"alice@example.com",
"noreply+@example.com",
]) {
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-email-",
title: "Email mention regression",
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("complete a workspace file mention with Enter", async () => {
await selectReadmeMention(page);
});
await test.step("send an email without offering or inserting workspace files", async () => {
await sendEmailIntact(page, email);
});
} finally {
await agent.cleanup();
}
});
}
@@ -96,6 +96,64 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
title: "Non-ASCII file link",
initialPrompt: "Show the report link",
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
});
try {
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
await openAgentRoute(page, session);
await openAssistantMarkdownLink(page, "报告");
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText("Report opened", { exact: true }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
} finally {
await session.cleanup();
}
});
for (const order of ["raw-first", "href-first"] as const) {
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "percent-link-cache-",
title: "Distinct file links",
initialPrompt: "Show both filenames",
featureValues: {
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
},
});
try {
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
await openAgentRoute(page, session);
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
for (const link of links) {
await returnToAssistant(page, session.agentId);
if (link === "raw") {
await openAssistantInlinePath(page, "literal%20.md");
await expectFileTabOpen(page, "literal%20.md");
await expectPreviewText(page, "Literal filename");
} else {
await openAssistantMarkdownLink(page, "Decoded");
await expectFileTabOpen(page, "literal .md");
await expectPreviewText(page, "Decoded filename");
}
}
} finally {
await session.cleanup();
}
});
}
test("jumps to each relative reference to the same file", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "relative-reference-qa-",
@@ -757,3 +815,24 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
});
});
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
await page.getByRole("link", { name, exact: true }).first().click();
}
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
}
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
await page.getByText(token, { exact: true }).click();
}
async function expectPreviewText(page: Page, text: string): Promise<void> {
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText(text, { exact: true }),
).toBeVisible();
}
@@ -1,3 +1,4 @@
import type { Page, TestInfo } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { test, expect } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 });
async function openTabSwitcher(page: Page) {
await page.getByRole("button", { name: /Switch tabs/ }).click();
const footer = page.getByRole("button", { name: "New terminal", exact: true });
await expect(footer).toBeVisible();
// Visibility alone passes while the sheet is still travelling up from below the viewport.
await expect
.poll(async () => {
const bounds = await footer.boundingBox();
return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(MOBILE_VIEWPORT.height);
}
async function captureScreen(page: Page, testInfo: TestInfo, name: string) {
await page.screenshot({ path: testInfo.outputPath(name + ".png") });
}
async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") {
await openTabSwitcher(page);
await page.getByRole("button", { name: kind, exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
@@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
test.describe("Workspace session rename (compact tab switcher)", () => {
test("renames an agent session from the switcher's per-session actions sheet", async ({
page,
}) => {
}, testInfo) => {
test.setTimeout(120_000);
await page.setViewportSize(MOBILE_VIEWPORT);
@@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
timeout: 30_000,
});
await captureScreen(page, testInfo, "compact-chat-header");
// Open the sessions dropdown (a bottom sheet on compact).
await page.getByRole("button", { name: /Switch tabs/ }).click();
await openTabSwitcher(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
{
timeout: 10_000,
@@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
await expect(page.getByText(renamed, { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
await expect(page.getByText("Rename agent", { exact: true })).toBeHidden();
await captureScreen(page, testInfo, "renamed-tab-in-switcher");
} finally {
await session.cleanup();
}
});
});
test("switcher footer creates tabs and returns to the existing chat", async ({
page,
}, testInfo) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "compact-header-actions-",
title: "Existing chat",
});
try {
await openAgentRoute(page, session);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await createTabFromSwitcher(page, "New terminal");
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2");
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
await captureScreen(page, testInfo, "compact-terminal-header");
await createTabFromSwitcher(page, "New agent");
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
await expect(
page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }),
).toHaveCount(1);
await captureScreen(page, testInfo, "compact-draft-header");
await openTabSwitcher(page);
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
await captureScreen(page, testInfo, "switcher-three-tabs");
await page.getByText("Existing chat", { exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
await openTabSwitcher(page);
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.click({ position: { x: 10, y: 10 } });
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await captureScreen(page, testInfo, "returned-chat-header");
} finally {
await session.cleanup();
}
});
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-surface\"" 10000
press "id=\"terminal-surface\""
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
@@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen
ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host_scenario chat
ad keyboard dismiss || true
ad press 'id="workspace-header-menu-trigger"' --settle
ad wait 'id="workspace-header-new-agent"' 10000
ad press 'id="workspace-header-new-agent"' --settle
ad press 'id="workspace-tab-switcher-trigger"' --settle
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
ad press 'id="workspace-tab-switcher-new-agent"' --settle
ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
ad keyboard dismiss || true
@@ -30,7 +30,6 @@ async function capture(
name: string,
subject: Locator = headerButtons(page),
) {
await expect(headerButtons(page)).toHaveCount(1);
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = info.outputPath(`${name}.png`);
@@ -80,6 +79,7 @@ export async function withButtonAuthorExample(
refreshFromHeaderAndComposer: () =>
test.step("icon-only header action and labeled composer action update in place", async () => {
await exampleCommand(page, "action");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
@@ -88,7 +88,13 @@ export async function withButtonAuthorExample(
).toContainText("Refreshed · 2");
await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT);
await capture(page, info, "02-compact-actions");
await expect(headerButtons(page)).toHaveCount(0);
await capture(
page,
info,
"02-compact-actions",
page.getByRole("button", { name: "Refresh context", exact: true }),
);
}),
useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
@@ -128,7 +134,8 @@ export async function withButtonAuthorExample(
await capture(page, info, "05-disabled");
await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT);
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveCount(0);
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport();
@@ -143,6 +150,7 @@ export async function withButtonAuthorExample(
inspectLiveWorkspaceDetails: () =>
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
await exampleCommand(page, "popover");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
@@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) {
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
}
async function holdButton(page: Page, button: Locator) {
await button.hover();
await page.mouse.down();
}
async function openCompactOverflowWithHeaderChrome(page: Page) {
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
const action = page.getByRole("button", { name: "Deploy application", exact: true });
const overflow = page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "More actions", exact: true });
await expect(action).toHaveCSS("border-top-width", "0px");
await expect(overflow).toHaveCSS("border-top-width", "0px");
await hamburger.hover();
const highlight = await hamburger.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await action.hover();
await expect(action).toHaveCSS("background-color", highlight);
await holdButton(page, overflow);
await expect(overflow).toHaveCSS("background-color", highlight);
await expectSurfaceVisible(page);
await page.mouse.up();
}
export async function withButtonShowcase(
page: Page,
run: (buttons: {
@@ -408,7 +383,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -426,39 +401,12 @@ export async function withButtonShowcase(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
await expectSurfaceVisible(page);
await openCompactOverflowWithHeaderChrome(page);
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Header checks");
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await expectSurfaceVisible(
page,
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await showcase.setTitle("Compact checks");
await press(page, "Header checks");
await expect(
page
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
).toHaveCount(0);
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
await expect(
page.getByRole("button", { name: "Open menu", exact: true }),
).toBeInViewport();
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
@@ -42,6 +42,7 @@ import {
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
@@ -274,12 +275,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
const topOverlayInset = useStreamTopOverlayInset();
// The list is inverted, so its content's bottom padding is the visual top.
const listContentContainerStyle = useMemo(
() => [
baseListContentContainerStyle,
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
{
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
},
],
[
baseListContentContainerStyle,
streamKeyboardInset.contentContainerPaddingBottom,
topOverlayInset,
],
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
);
const listInsetProps = useMemo(
() =>
@@ -0,0 +1,14 @@
import { createContext, useContext } from "react";
import { isNative } from "@/constants/platform";
/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
export const supportsStreamTopOverlay = isNative;
const StreamTopOverlayContext = createContext(0);
/** Visual-top clearance supplied by the containing layout, in points. */
export const StreamTopOverlayProvider = StreamTopOverlayContext.Provider;
export function useStreamTopOverlayInset(): number {
return useContext(StreamTopOverlayContext);
}
+1 -1
View File
@@ -35,7 +35,7 @@ import { ChangelogHost } from "@/changelog";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
@@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => {
}
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
});
it("decodes percent-encoded relative markdown hrefs", () => {
// markdown-it percent-encodes non-ASCII characters in hrefs, so a link
// written as [x](docs/reports/开户赠金.md) arrives here percent-encoded.
expect(
parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md",
path: "/Users/test/project/docs/reports/开户赠金.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("decodes percent-encoded spaces in relative markdown hrefs", () => {
expect(
parseAssistantFileLink("docs/my%20file.md#L5", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "docs/my%20file.md#L5",
path: "/Users/test/project/docs/my file.md",
lineStart: 5,
lineEnd: undefined,
});
});
it("decodes percent-encoded absolute hrefs with line suffixes", () => {
expect(
parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "/tmp/%E5%BC%80%E6%88%B7.md:3",
path: "/tmp/开户.md",
lineStart: 3,
lineEnd: undefined,
});
});
it("decodes percent-encoded home-relative hrefs", () => {
expect(
parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "~/notes/%E5%BC%80%E6%88%B7.md",
path: "~/notes/开户.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("decodes percent-encoded Windows hrefs", () => {
expect(
parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", {
workspaceRoot: "C:/repo",
decodeHref: true,
}),
).toEqual({
raw: "C:/repo/my%20dir/app.tsx#L8",
path: "C:/repo/my dir/app.tsx",
lineStart: 8,
lineEnd: undefined,
});
});
it("keeps literal percent signs in raw paths that are not hrefs", () => {
// Inline-code and linkified tokens are raw file paths: a real file named
// "my%20file.md" must not be decoded into "my file.md".
expect(
parseAssistantFileLink("docs/my%20file.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my%20file.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("/tmp/my%20file.md:3", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/my%20file.md:3",
path: "/tmp/my%20file.md",
lineStart: 3,
lineEnd: undefined,
});
});
it("keeps non-ASCII characters in raw absolute paths", () => {
// `new URL` percent-encodes non-ASCII, so raw paths must not be routed
// through URL parsing — `/tmp/开户.md` must round-trip unchanged.
expect(
parseAssistantFileLink("/tmp/开户.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/开户.md",
path: "/tmp/开户.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("/tmp/开户.md#L4", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/开户.md#L4",
path: "/tmp/开户.md",
lineStart: 4,
lineEnd: undefined,
});
});
});
describe("normalizeInlinePathTarget", () => {
@@ -469,3 +591,23 @@ describe("normalizeInlinePathTarget", () => {
});
});
});
describe("encoded filename line markers", () => {
it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])(
"keeps encoded line syntax in the filename %s",
(href) => {
expect(
parseAssistantFileLink(href, {
workspaceRoot: "/workspace",
decodeHref: true,
}),
).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` });
expect(
parseAssistantFileLink(href, {
workspaceRoot: "/workspace",
decodeHref: true,
})?.lineStart,
).toBeUndefined();
},
);
});
+98 -38
View File
@@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/**
* 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
* text (inline code, linkified tokens) is not encoded; decoding it would
* send a real file named `my%20file.md` to `my file.md`.
*/
decodeHref?: boolean;
}
export type AssistantFileLinkClassification =
@@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
};
}
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
function parseAssistantInlinePathLink(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): InlinePathTarget | null {
const inlinePathTarget = parseInlinePathToken(value);
if (!inlinePathTarget) {
return null;
}
const normalizedPath = normalizePathToken(inlinePathTarget.path);
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -277,7 +291,7 @@ export function classifyAssistantFileLink(
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -308,32 +322,19 @@ export function parseAssistantFileLink(
return null;
}
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
if (inlinePathTarget) {
return inlinePathTarget;
}
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (windowsPathMatch) {
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
if (!normalizedPath) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
return {
raw: value,
path: normalizedPath,
...lines,
};
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
if (windowsTarget) {
return windowsTarget;
}
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
});
if (relativeTarget) {
return relativeTarget;
@@ -343,19 +344,33 @@ export function parseAssistantFileLink(
return null;
}
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed, "http://paseo.invalid");
} catch {
return null;
// Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
// normalize before decode. Raw paths must not: `new URL` percent-encodes
// non-ASCII characters, which would corrupt `/tmp/开户.md` into the
// still-encoded name.
let pathPart: string;
let hashPart: string;
if (options.decodeHref) {
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed, "http://paseo.invalid");
} catch {
return null;
}
pathPart = safeDecodeURIComponent(parsedUrl.pathname);
hashPart = parsedUrl.hash;
} else {
const hashIndex = trimmed.indexOf("#");
pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
}
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
const lines = parseLineFragment(parsedUrl.hash);
const lines = parseLineFragment(hashPart);
if (!lines) {
return null;
}
@@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean {
return isPlausibleAssistantLocalPath(path);
}
function parseWindowsFileLink(
trimmed: string,
raw: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): InlinePathTarget | null {
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (!windowsPathMatch) {
return null;
}
let pathPart = windowsPathMatch[1] ?? "";
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
return {
raw,
path: normalizedPath,
...lines,
};
}
function parseWorkspaceRelativeFileLink(
value: string,
options: AssistantHrefParseOptions,
): InlinePathTarget | null {
const parsed = parseLocalPathParts(value);
const parsed = parseLocalPathParts(value, options);
if (!parsed || isAbsolutePath(parsed.path)) {
return null;
}
@@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
} | null {
const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?")) {
return null;
@@ -436,14 +486,19 @@ function parseLocalPathParts(
return null;
}
// Split literal line markers before decoding; encoded punctuation belongs
// to the filename. Raw inline-code/path text stays untouched.
const inlinePathTarget = parseInlinePathToken(beforeHash);
if (inlinePathTarget) {
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!isPlausibleAssistantLocalPath(pathPart)) {
return null;
}
return {
path: inlinePathTarget.path,
path: pathPart,
lines: {
lineStart: inlinePathTarget.lineStart,
lineEnd: inlinePathTarget.lineEnd,
@@ -455,12 +510,17 @@ function parseLocalPathParts(
return null;
}
if (!isPlausibleAssistantLocalPath(beforeHash)) {
let decodedBeforeHash = beforeHash;
if (options.decodeHref) {
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
return null;
}
return {
path: beforeHash,
path: decodedBeforeHash,
lines: fragmentLines,
};
}
@@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean {
function isAmbiguousWorkspaceCandidate(
value: string,
target: InlinePathTarget,
workspaceRoot?: string,
options: AssistantHrefParseOptions = {},
): boolean {
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
return false;
}
const parsed = parseLocalPathParts(value);
const parsed = parseLocalPathParts(value, options);
if (!parsed || isAbsolutePath(parsed.path)) {
return false;
}
@@ -1,4 +1,8 @@
import { describe, expect, it } from "vitest";
import MarkdownIt from "markdown-it";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
classifyForResolution,
fetchDaemonResolution,
@@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = {
workspaceRoot: "/Users/test/project",
};
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 {
await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true });
await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content");
const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {});
const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href");
expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md");
const resolution = classifyForResolution({ href: href! }, { workspaceRoot });
expect(resolution.kind).toBe("resolved");
if (resolution.kind !== "resolved" || resolution.value.kind !== "file") {
throw new Error("Expected a directly resolved file link");
}
expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content");
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
});
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
@@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => {
};
describe("classifyForResolution", () => {
it.each([
["C:/repo/my%20file.md", undefined],
["C:\\repo\\my%20file.md", undefined],
["C:/repo/my%20file.md:12", 12],
["C:\\repo\\my%20file.md:12", 12],
])("keeps the literal filename in Windows inline code %s", (href, lineStart) => {
for (const text of [href, undefined]) {
const result = classifyForResolution(
{ href, text, sourceType: "inline-code" },
{ workspaceRoot: "C:/repo" },
);
expect(result).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } },
});
}
expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } },
});
});
it("returns the directFile target synchronously", () => {
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
@@ -165,6 +209,46 @@ describe("classifyForResolution", () => {
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
});
it("decodes percent-encoded markdown hrefs", () => {
const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT);
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my file.md",
lineStart: undefined,
lineEnd: undefined,
},
},
});
});
it("keeps literal percent signs in inline-code paths", () => {
const result = classifyForResolution(
{
href: "docs/my%20file.md",
text: "docs/my%20file.md",
sourceType: "inline-code",
},
CONTEXT,
);
expect(result).toEqual({
kind: "needsLookup",
ambiguousQuery: "docs/my%20file.md",
token: "docs/my%20file.md",
target: {
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my%20file.md",
lineStart: undefined,
lineEnd: undefined,
},
});
});
});
describe("fetchDaemonResolution", () => {
@@ -108,13 +108,15 @@ export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
): AssistantFileLinkResolution {
const token = getAssistantFileLinkToken(source).trim();
const tokenInfo = getAssistantFileLinkTokenInfo(source);
const token = tokenInfo.token.trim();
if (!token) {
return { kind: "resolved", value: { kind: "ignored" } };
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
@@ -148,14 +150,26 @@ export function classifyForResolution(
}
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
return getAssistantFileLinkTokenInfo(source).token;
}
// Markdown link hrefs are percent-encoded by the renderer; inline-code and
// linkified text are raw file paths that must not be decoded.
function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): {
token: string;
fromHref: boolean;
} {
if (source.sourceType === "inline-code") {
return { token: source.text?.trim() || source.href, fromHref: false };
}
if (isLinkifiedSource(source)) {
const text = source.text?.trim();
if (text && isFileLookingAssistantToken(text)) {
return text;
return { token: text, fromHref: false };
}
}
return source.href;
return { token: source.href, fromHref: true };
}
export function getAmbiguousSuggestionQuery(
@@ -29,14 +29,21 @@ export interface AssistantFileLinkActions {
canResolveFile(source: AssistantFileLinkSource): boolean;
}
interface AssistantFileLinkLookupKey {
query: string;
path: string;
lineStart: number | null;
lineEnd: number | null;
}
type AssistantFileLinkQueryKey = readonly [
"assistantFileLink",
string | null,
string | null,
string,
AssistantFileLinkLookupKey | null,
];
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const { t } = useTranslation();
@@ -59,7 +66,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
token: resolution.token,
resolution,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
@@ -179,7 +186,7 @@ function openAssistantFileLink(input: {
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
token: capturedResolution.token,
resolution: capturedResolution,
});
const run = async () => {
@@ -245,9 +252,19 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
token: string;
resolution: Extract<AssistantFileLinkResolution, { kind: "needsLookup" }>;
}): AssistantFileLinkQueryKey {
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
return [
"assistantFileLink",
input.serverId ?? null,
input.workspaceRoot ?? null,
{
query: input.resolution.ambiguousQuery,
path: input.resolution.target.path,
lineStart: input.resolution.target.lineStart ?? null,
lineEnd: input.resolution.target.lineEnd ?? null,
},
];
}
async function dispatchResolvedLink(input: {
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
describe("getCompactSheetSafeAreaPadding", () => {
it("assigns safe clearance to the footer independently of decorative padding", () => {
@@ -49,17 +46,3 @@ describe("getCompactSheetSafeAreaPadding", () => {
).toEqual({});
});
});
describe("getBottomSheetVisibleContentHeight", () => {
it("stops subtracting the retained keyboard height after the keyboard hides", () => {
const layout = {
containerHeight: 874,
contentPosition: 88,
handleHeight: 24,
keyboardHeight: 344,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
});
});
@@ -10,28 +10,6 @@ export interface CompactSheetSafeAreaPadding {
footerPaddingBottom?: number;
}
interface BottomSheetVisibleContentHeightInput {
containerHeight: number;
contentPosition: number;
handleHeight: number;
keyboardHeight: number;
isKeyboardVisible: boolean;
}
export function getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
handleHeight,
keyboardHeight,
isKeyboardVisible,
}: BottomSheetVisibleContentHeightInput): number {
"worklet";
return Math.max(
0,
containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
);
}
export function getCompactSheetSafeAreaPadding({
isCompact,
isKeyboardVisible,
@@ -12,22 +12,16 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import {
KEYBOARD_STATUS,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import { ScrollView } from "@/components/ui/scroll-view";
import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
@@ -207,10 +201,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
bottomSheetVisibleContent: {
minHeight: 0,
overflow: "hidden",
},
bottomSheetVisibleScroll: {
flex: 1,
minHeight: 0,
@@ -263,40 +253,6 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
return <View style={[styles.sheetContent, style]}>{children}</View>;
}
function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleContentStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
if (containerHeight < 0 || handleHeight < 0) {
return { height: 0 };
}
const initialDetentPosition = animatedDetentsState.get().detents?.[0];
const contentPosition =
initialDetentPosition == null
? animatedPosition.get()
: Math.min(animatedPosition.get(), initialDetentPosition);
const keyboardState = animatedKeyboardState.get();
return {
height: getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
handleHeight,
keyboardHeight: keyboardState.heightWithinContainer,
isKeyboardVisible: keyboardState.status === KEYBOARD_STATUS.SHOWN,
}),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
{children}
</Animated.View>
);
}
export function SheetHeaderView({
header,
onClose,
@@ -471,8 +427,6 @@ export interface AdaptiveModalSheetProps {
bodyStyle?: StyleProp<ViewStyle>;
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
contentStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
contextBridge?: ContextBridge | null;
}
@@ -493,7 +447,6 @@ export function AdaptiveModalSheet({
presentation,
contentStyle,
bodyStyle,
sizeContentToCurrentSnapPoint = true,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
@@ -519,11 +472,15 @@ export function AdaptiveModalSheet({
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
[compactSafeAreaPadding.footerPaddingBottom],
);
const footerView = footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null;
const footerView = useMemo(
() =>
footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null,
[footer, footerClearanceStyle, footerContainerStyle],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
@@ -628,7 +585,6 @@ export function AdaptiveModalSheet({
</View>
)}
</View>
{footerView}
</>
);
@@ -650,11 +606,7 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
{sizeContentToCurrentSnapPoint ? (
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
) : (
sheetContent
)}
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
</IsolatedBottomSheetModal>
);
}
@@ -0,0 +1,99 @@
import { useCallback, useId, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
const FADE_OVERHANG = 24;
interface FloatingHeaderLayoutProps {
header: ReactNode;
/** Float only when the content supports visual-top clearance. */
floating: boolean;
children: (topOverlayInset: number) => ReactNode;
}
/**
* Stacks a header above content, or floats it over the content behind a fade. Children keep the
* same tree position in both modes, so toggling `floating` never remounts them.
*/
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
const [headerHeight, setHeaderHeight] = useState(0);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
}, []);
return (
<View style={styles.root}>
{floating ? null : header}
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none" onLayout={handleHeaderLayout}>
<FloatingHeaderFade />
{header}
</View>
) : null}
</View>
);
}
function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) {
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={1} />
<Stop offset="50%" stopColor={color} stopOpacity={0.97} />
<Stop offset="75%" stopColor={color} stopOpacity={0.8} />
<Stop offset="100%" stopColor={color} stopOpacity={0} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedFloatingHeaderFadeSvg = withUnistyles(FloatingHeaderFadeSvg);
const workspaceSurfaceColorMapping = (theme: Theme) => ({
color: theme.colors.surfaceWorkspace,
});
function FloatingHeaderFade() {
// React-generated ids contain characters that are invalid inside SVG fragment references.
const gradientId = `floating-header-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<View style={styles.fade} pointerEvents="none">
<ThemedFloatingHeaderFadeSvg
gradientId={gradientId}
uniProps={workspaceSurfaceColorMapping}
/>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
},
content: {
flex: 1,
minHeight: 0,
},
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
},
fade: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: -FADE_OVERHANG,
},
});
@@ -6,10 +6,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { CompactHeader } from "./compact-header";
import { useIsCompactFormFactor } from "@/constants/layout";
interface BackHeaderProps {
title?: string;
titleAccessory?: ReactNode;
rightContent?: ReactNode;
onBack?: () => void;
}
@@ -18,7 +19,15 @@ function goBack(): void {
router.back();
}
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const isCompact = useIsCompactFormFactor();
if (isCompact) {
return <CompactHeader navigation="back" title={title} onBack={onBack} actions={rightContent} />;
}
return <DesktopBackHeader title={title} rightContent={rightContent} onBack={onBack} />;
}
function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleBack = useCallback(() => {
@@ -42,7 +51,6 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && <ScreenTitle>{title}</ScreenTitle>}
{titleAccessory}
</>
}
right={rightContent}
@@ -56,10 +64,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
backButton: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
}));
@@ -0,0 +1,168 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const ISLAND_PADDING = 7;
const NO_SHORTCUT: ShortcutKey[] = [];
interface CompactHeaderProps {
/** The leading island: the sidebar menu, or a back button. */
navigation: "menu" | "back";
/** Back button action. Defaults to navigating back. */
onBack?: () => void;
title?: string;
titleTestID?: string;
/** A line under the title. */
subtitle?: ReactNode;
/** Show a placeholder instead of the title while it loads. */
loading?: boolean;
/** Icon actions inherit the island chrome through the shared button primitives. */
actions?: ReactNode;
}
/**
* The phone header: the navigation button and the screen's actions sit in islands, with the title
* left-aligned between them. It has no background of its own, so it reads the same in flow and floating
* over content (see `FloatingHeaderLayout`). Its height matches the old flat header, so anything
* offset by the header height still lines up.
*/
export function CompactHeader({
navigation,
onBack,
title,
titleTestID,
subtitle,
loading = false,
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
const rootStyle = useMemo(
() => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }],
[insets.top],
);
return (
<IconButtonChromeProvider value="header-island">
<View style={rootStyle}>
<View style={styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
</View>
<View style={styles.titleGroup}>
<CompactHeaderTitle
loading={loading}
title={title}
titleTestID={titleTestID}
subtitle={subtitle}
/>
</View>
{actions ? <View style={styles.island}>{actions}</View> : null}
</View>
</View>
</IconButtonChromeProvider>
);
}
function CompactHeaderTitle({
loading,
title,
titleTestID,
subtitle,
}: Pick<CompactHeaderProps, "title" | "titleTestID" | "subtitle"> & { loading: boolean }) {
if (loading) {
return <View style={styles.titleSkeleton} />;
}
return (
<>
{title ? (
<Text style={styles.title} numberOfLines={1} testID={titleTestID}>
{title}
</Text>
) : null}
{subtitle}
</>
);
}
function BackButton({ onBack }: { onBack?: () => void }) {
const { t } = useTranslation();
const handlePress = useCallback(() => {
if (onBack) {
onBack();
return;
}
router.back();
}, [onBack]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("common.actions.back")}
tooltipKeys={NO_SHORTCUT}
tooltipSide="right"
testID="header-back-button"
accessibilityRole="button"
accessibilityLabel={t("common.actions.back")}
>
<ThemedArrowLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[3],
},
row: {
height: HEADER_INNER_HEIGHT_MOBILE,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
titleGroup: {
flex: 1,
minWidth: 0,
// Both title lines receive the remaining width; text alignment does not size the slot.
alignItems: "stretch",
},
island: {
flexDirection: "row",
alignItems: "center",
padding: ISLAND_PADDING,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
...theme.shadow.sm,
},
title: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left",
},
titleSkeleton: {
width: 140,
maxWidth: "100%",
height: 22,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
opacity: 0.25,
},
}));
@@ -1,4 +1,4 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { type ReactElement, type ReactNode } from "react";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -7,7 +7,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
import {
iconButtonChromeFrameStyle,
iconButtonChromeStyle,
useIconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState {
@@ -46,17 +46,11 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: null;
const combinedStyle = useMemo(
() =>
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
iconButtonChromeStyle({
size: "large",
state: { hovered: Boolean(hovered), pressed },
disabled: Boolean(disabled),
style,
}),
[disabled, style],
);
const combinedStyle = useIconButtonChromeStyle({
size: "large",
disabled: Boolean(disabled),
style,
});
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
@@ -1,16 +1,10 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import type { ReactNode } from "react";
import { StyleSheet } from "react-native-unistyles";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { CompactHeader } from "./compact-header";
import { SidebarMenuToggle } from "./sidebar-menu-toggle";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
interface MenuHeaderProps {
title?: string;
@@ -18,130 +12,11 @@ interface MenuHeaderProps {
borderless?: boolean;
}
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 1.5;
function MobileMenuIcon({ color }: { color: string }) {
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
const shortLineStyle = useMemo(
() => [styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }],
[color],
);
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={lineStyle} />
<View style={lineStyle} />
<View style={shortLineStyle} />
</View>
);
}
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = useMemo(
() => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
[],
);
const handlePress = useCallback(() => {
toggleAgentListForLayout({ isCompact: isMobile });
}, [toggleAgentListForLayout, isMobile]);
const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={resolvedStyle}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
) : (
<PanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
)}
</HeaderToggleButton>
);
}
export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const isMobile = useIsCompactFormFactor();
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
const placeholderStyle = useMemo(
() => [headerIconSlotStyle.slot, resolvedStyle],
[resolvedStyle],
);
if (!isMobile && !ownsTopLeft) {
return null;
}
if (!isMobile && hasTopLeftWindowControls) {
return (
<View pointerEvents="none" style={placeholderStyle}>
<View style={styles.desktopMenuIconSpace} />
</View>
);
}
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
}
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
const isCompact = useIsCompactFormFactor();
if (isCompact) {
return <CompactHeader navigation="menu" title={title} actions={rightContent} />;
}
return (
<ScreenHeader
left={
@@ -158,31 +33,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
}
const styles = StyleSheet.create((theme) => ({
leadingToggle: {
marginLeft: {
xs: 0,
md: -theme.spacing[2],
},
},
left: {
gap: theme.spacing[2],
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
desktopMenuIconSpace: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));
@@ -0,0 +1,167 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import {
extraMutedIconColorMapping,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
const ThemedPanelLeft = withUnistyles(PanelLeft);
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 1.5;
function MobileMenuIcon({ extraMuted }: { extraMuted: boolean }) {
const color = extraMuted ? styles.mobileMenuLineExtraMuted : styles.mobileMenuLineMuted;
const lineStyle = useMemo(() => [styles.mobileMenuLine, color], [color]);
const shortLineStyle = useMemo(
() => [styles.mobileMenuLine, styles.mobileMenuLineShort, color],
[color],
);
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={lineStyle} />
<View style={lineStyle} />
<View style={shortLineStyle} />
</View>
);
}
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { t } = useTranslation();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = useMemo(
() => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
[],
);
const handlePress = useCallback(() => {
toggleAgentListForLayout({ isCompact: isMobile });
}, [toggleAgentListForLayout, isMobile]);
const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={resolvedStyle}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon extraMuted={extraMutedIdleIcon} />
) : (
<ThemedPanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={extraMutedIdleIcon ? extraMutedIconColorMapping : mutedIconColorMapping}
/>
)}
</HeaderToggleButton>
);
}
export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const isMobile = useIsCompactFormFactor();
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
const placeholderStyle = useMemo(
() => [headerIconSlotStyle.slot, resolvedStyle],
[resolvedStyle],
);
if (!isMobile && !ownsTopLeft) {
return null;
}
if (!isMobile && hasTopLeftWindowControls) {
return (
<View pointerEvents="none" style={placeholderStyle}>
<View style={styles.desktopMenuIconSpace} />
</View>
);
}
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
}
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
const styles = StyleSheet.create((theme) => ({
leadingToggle: {
marginLeft: {
xs: 0,
md: -theme.spacing[2],
},
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
desktopMenuIconSpace: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineMuted: {
backgroundColor: theme.colors.foregroundMuted,
},
mobileMenuLineExtraMuted: {
backgroundColor: theme.colors.foregroundExtraMuted,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));
+25 -30
View File
@@ -1,5 +1,13 @@
import { router } from "expo-router";
import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
import {
CircleGauge,
FolderPlus,
GitBranch,
Server,
Settings,
X,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -26,7 +34,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { DensityIcon } from "@/components/ui/density-icon";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -37,6 +45,7 @@ import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
@@ -280,30 +289,25 @@ function FooterIconButton({
onPress,
testID,
label,
icon: Icon,
icon,
iconSizeAdjustment = 0,
shortcutKeys,
theme,
}: {
onPress: () => void;
testID: string;
label: string;
icon: typeof FolderPlus;
icon: LucideIcon;
/** Only for a glyph that reads larger than the others at the same size. */
iconSizeAdjustment?: number;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
const isCompact = useIsCompactFormFactor();
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
ref={buttonRef}
style={styles.footerIconButton(isCompact)}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
collapsable={false}
@@ -313,9 +317,11 @@ function FooterIconButton({
onPress={onPress}
>
{({ hovered }) => (
<Icon
size={iconSize + iconSizeAdjustment}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
<DensityIcon
icon={icon}
size="md"
sizeAdjustment={iconSizeAdjustment}
color={hovered ? "foreground" : "foregroundMuted"}
/>
)}
</Pressable>
@@ -328,12 +334,10 @@ function FooterIconButton({
}
function SidebarHostPicker({
theme,
label,
onAddHost,
onOpenHostSettings,
}: {
theme: SidebarTheme;
label: string;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
@@ -377,7 +381,6 @@ function SidebarHostPicker({
icon={Server}
// Server's two boxes fill more of the square than the other glyphs.
iconSizeAdjustment={-1}
theme={theme}
/>
</HostPicker>
);
@@ -399,7 +402,6 @@ function IconTooltipContent({
}
function SidebarFooter({
theme,
handleOpenProject,
handleSettings,
labels,
@@ -407,7 +409,6 @@ function SidebarFooter({
handleOpenHostSettings,
onBeforeNavigate,
}: {
theme: SidebarTheme;
handleOpenProject: () => void;
handleSettings: () => void;
labels: {
@@ -436,11 +437,9 @@ function SidebarFooter({
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<SidebarUsageIcon label={labels.usage} theme={theme} />
<SidebarUsageIcon label={labels.usage} />
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
@@ -453,7 +452,6 @@ function SidebarFooter({
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
</View>
@@ -461,7 +459,7 @@ function SidebarFooter({
);
}
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
function SidebarUsageIcon({ label }: { label: string }) {
const openUsage = useOpenSidebarUsage();
return (
<FooterIconButton
@@ -469,7 +467,6 @@ function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme
testID="sidebar-usage-icon"
label={label}
icon={CircleGauge}
theme={theme}
/>
);
}
@@ -612,7 +609,6 @@ function MobileSidebar({
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
@@ -789,7 +785,6 @@ function DesktopSidebar({
<SidebarCalloutSlot />
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
@@ -957,14 +952,14 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1.5],
gap: 2,
},
footerIconButton: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
footerIconButton: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
}),
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -391,6 +391,43 @@ describe("splitHtmlishMarkdown", () => {
);
});
it("keeps table rows after a comparison that only looks like a tag", () => {
const source = [
"| ID | Note | Status |",
"|---|---|---|",
"| a-1 | accepts 1<size<2 only | open |",
"| a-2 | second row | open |",
"",
"Later x -> y.",
].join("\n");
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it.each(["See <https://example.com/docs> for more.", "Stop when a<b", "Keep </ b> and a <b"])(
"keeps %j literal because it is not HTML",
(source) => {
expect(normalizeHtmlishMarkdown(source)).toBe(source);
},
);
it.each([
"Call As<Integer>() before limits<DWORD>::max()",
"<Kbd Data-Key='K'>Ctrl</Kbd> then <Foo/>",
])("echoes unknown tags in %j as written", (source) => {
expect(normalizeHtmlishMarkdown(source)).toBe(source);
});
it("rebuilds an unknown tag that a code span splits", () => {
expect(normalizeHtmlishMarkdown('<Tip title="`x`">hi</Tip>')).toBe("`x`<tip title>hi</Tip>");
});
it("still reads tags and comments next to literal angle brackets", () => {
expect(normalizeHtmlishMarkdown("1<n<2 <b>bold</b><!-- hidden --> 3<4")).toBe(
"1<n<2 **bold** 3<4",
);
});
it("falls back to inert markdown when details are unclosed", () => {
const source = "<details><summary>Open</summary>Still open";
@@ -29,6 +29,12 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately.
const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
const HTML_TAG_RE = new RegExp(
String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`,
"y",
);
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
b: ["**", "**"],
@@ -373,14 +379,16 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
}
const children = tokens.slice(index + 1, closeIndex);
output += renderHtmlTag(token, children);
const close = tokens[closeIndex];
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
output += renderHtmlTag(token, children, closeRaw);
index = closeIndex;
}
return output;
}
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
@@ -400,7 +408,7 @@ function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
@@ -606,7 +614,15 @@ function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
let stripNextLeadingLineBreak = false;
let skippedLength = 0;
let writtenLength = 0;
let chunkStart = 0;
let parser!: Parser;
// Unknown tags render as text, so keep them as written: the parser lowercases names
// and rewrites attributes. A tag split by a protected range has no single slice.
const readRawTag = (): string | null =>
parser.startIndex < chunkStart
? null
: source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength);
parser = new Parser(
{
onopentag(name, attributes, isImplied) {
@@ -620,7 +636,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: false,
selfClosing,
attributes,
raw: renderStartTag(name, attributes),
raw: readRawTag() ?? renderStartTag(name, attributes),
});
},
onclosetag(name, isImplied) {
@@ -633,7 +649,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: true,
selfClosing: false,
attributes: {},
raw: `</${name}>`,
raw: readRawTag() ?? `</${name}>`,
});
},
ontext(value) {
@@ -660,6 +676,8 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
);
return {
write(chunk) {
chunkStart = writtenLength;
writtenLength += chunk.length;
parser.write(chunk);
},
skip(length) {
@@ -698,7 +716,58 @@ function escapeAttribute(value: string): string {
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
const fencedRanges = getFencedCodeRanges(source);
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
const codeRanges = mergeProtectedRanges([
...fencedRanges,
...getInlineCodeRanges(source, fencedRanges),
]);
return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]);
}
/**
* htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up
* to the next `>`, so `1<n<2` in a table cell swallows the rows after it and an autolink loses
* its slashes. Hand it only what CommonMark would read as raw HTML; every other `<` is text.
*/
function getLiteralAngleBracketRanges(
source: string,
codeRanges: ProtectedMarkdownRange[],
): ProtectedMarkdownRange[] {
const ranges: ProtectedMarkdownRange[] = [];
let codeIndex = 0;
// Reused across comment openers so unclosed comments stay linear.
let commentClose = -1;
let index = source.indexOf("<");
while (index !== -1) {
while ((codeRanges[codeIndex]?.end ?? Infinity) <= index) {
codeIndex += 1;
}
const code = codeRanges[codeIndex];
if (code && code.start <= index) {
index = source.indexOf("<", code.end);
continue;
}
let htmlEnd: number | null;
if (source.startsWith("<!--", index)) {
if (commentClose !== source.length && commentClose < index + 2) {
const close = source.indexOf("-->", index + 2);
commentClose = close === -1 ? source.length : close;
}
htmlEnd = commentClose === source.length ? null : commentClose + 3;
} else {
HTML_TAG_RE.lastIndex = index;
htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null;
}
if (htmlEnd === null) {
ranges.push({ start: index, end: index + 1 });
htmlEnd = index + 1;
}
index = source.indexOf("<", htmlEnd);
}
return ranges;
}
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
@@ -126,9 +126,6 @@ export function ProjectEditSheet({
onClose={handleClose}
footer={footer}
desktopMaxWidth={440}
// Bound the compact scroller to the live snap height so the footer stays
// on screen instead of being pushed past the bottom of the sheet.
sizeContentToCurrentSnapPoint
testID="project-edit-sheet"
>
<Field
@@ -30,6 +30,7 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -114,6 +115,7 @@ import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { DensityIcon } from "@/components/ui/density-icon";
import { PressHighlight } from "@/components/ui/press-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -822,9 +824,10 @@ function NewWorkspaceGhostRow({
{({ hovered, pressed }) => (
<>
<View style={styles.newWorkspaceGhostIconSlot}>
<ThemedPlus
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
<DensityIcon
icon={Plus}
size="sm"
color={hovered || pressed ? "foreground" : "foregroundMuted"}
/>
</View>
<Text
@@ -2519,7 +2522,7 @@ const styles = StyleSheet.create((theme) => ({
// the step in reads as belonging to that project. Padding rather than margin, so the hover and
// pressed fills stay the same box as every other row in the sidebar.
newWorkspaceGhostRow: {
minHeight: 36,
minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[4],
@@ -2539,27 +2542,27 @@ const styles = StyleSheet.create((theme) => ({
// The width of a workspace row's status slot, so the label lands on the same rail as the
// titles above it.
newWorkspaceGhostIconSlot: {
width: theme.iconSize.md,
height: theme.iconSize.md,
width: densityIconBox("md"),
height: densityIconBox("md"),
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
newWorkspaceGhostText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
minWidth: 0,
flexShrink: 1,
},
newWorkspaceGhostTextHovered: {
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
},
projectRow: {
position: "relative",
minHeight: 36,
minHeight: density.rowHeight,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -2600,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({
},
projectTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -2681,7 +2684,7 @@ const styles = StyleSheet.create((theme) => ({
right: theme.spacing[2],
},
workspaceRow: {
minHeight: 36,
minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -2,16 +2,11 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
import { density, densityFontSize } from "@/styles/density";
export function PinnedSectionHeader({
collapsed,
@@ -23,7 +18,6 @@ export function PinnedSectionHeader({
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
return (
<Pressable
@@ -37,7 +31,11 @@ export function PinnedSectionHeader({
<>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? (
<Chevron size={12} uniProps={foregroundMutedColorMapping} />
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null}
</>
)}
@@ -47,7 +45,7 @@ export function PinnedSectionHeader({
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: 36,
minHeight: density.rowHeight,
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
@@ -58,7 +56,7 @@ const styles = StyleSheet.create((theme) => ({
},
title: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontSize: densityFontSize(theme, "sm"),
fontWeight: theme.fontWeight.normal,
},
}));
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
getProjectStatusBadgeContent,
@@ -35,9 +36,8 @@ const STATUS_BADGE_OFFSET = -4;
//
// The filled alert occupies the full badge shell so needs-input remains more prominent than
// the passive status dots.
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
const DensityProjectIconView = withUnistyles(ProjectIconView);
const projectIconProps = densityIconProps("md", () => ({}));
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
@@ -230,12 +230,12 @@ function ProjectIcon({
projectViewKey: string;
}) {
return (
<ProjectIconView
<DensityProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectViewKey={projectViewKey}
size={ICON_SIZE.md}
textStyle={styles.projectIconFallbackText}
uniProps={projectIconProps}
/>
);
}
@@ -271,12 +271,12 @@ const styles = StyleSheet.create((theme) => {
return {
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
// only as tall as the icon puts it above the title and the kebab — which is why the
// workspace status indicator shares the same `rowLineHeight`.
projectLeadingVisualSlot: {
width: theme.iconSize.md,
height: LEADING_SLOT_HEIGHT,
width: densityIconBox("md"),
height: density.rowLineHeight,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -284,8 +284,8 @@ const styles = StyleSheet.create((theme) => {
// Anchors the corner badge to the icon rather than to the taller slot.
projectIconBox: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
width: densityIconBox("md"),
height: densityIconBox("md"),
},
projectIconFallbackText: {
fontSize: 9,
@@ -2,19 +2,11 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, ChevronUp } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
import type { Theme } from "@/styles/theme";
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronUp = withUnistyles(ChevronUp);
import { DensityIcon } from "@/components/ui/density-icon";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
/**
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
@@ -62,17 +54,11 @@ export function SidebarGroupToggleRow({
{({ hovered, pressed }) => (
<>
<View style={styles.iconSlot}>
{expanded ? (
<ThemedChevronUp
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
) : (
<ThemedChevronDown
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
<DensityIcon
icon={expanded ? ChevronUp : ChevronDown}
size="sm"
color={hovered || pressed ? "foreground" : "foregroundMuted"}
/>
</View>
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
{label}
@@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({
const styles = StyleSheet.create((theme) => ({
// Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx.
row: {
minHeight: 36,
minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -106,21 +92,21 @@ const styles = StyleSheet.create((theme) => ({
// The width of a workspace row's status slot, so the label lands on the same rail as the
// titles above it rather than two points to their left.
iconSlot: {
width: theme.iconSize.md,
height: theme.iconSize.md,
width: densityIconBox("md"),
height: densityIconBox("md"),
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
text: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
minWidth: 0,
flexShrink: 1,
},
textHovered: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
minWidth: 0,
flexShrink: 1,
},
@@ -1,22 +1,12 @@
import {
useCallback,
useMemo,
useState,
type ComponentType,
type ReactNode,
type Ref,
} from "react";
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { DensityIcon } from "@/components/ui/density-icon";
import { Shortcut } from "@/components/ui/shortcut";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { ShortcutKey } from "@/utils/format-shortcut";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
@@ -49,7 +39,7 @@ interface SidebarHeaderRowProps {
}
export function SidebarHeaderRow({
icon: Icon,
icon,
label,
onPress,
isActive = false,
@@ -64,9 +54,8 @@ export function SidebarHeaderRow({
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
const isHighlighted = isHovered || isActive;
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
const iconSize = variant === "header" ? "md" : "sm";
let right = trailing ?? null;
if (right === null && shortcutKeys && isHovered) {
@@ -91,10 +80,11 @@ export function SidebarHeaderRow({
aria-selected={isActive}
style={styles.button}
>
{ThemedIcon ? (
<ThemedIcon
{icon ? (
<DensityIcon
icon={icon}
size={iconSize}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
color={isHighlighted ? "foreground" : "foregroundMuted"}
/>
) : (
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
@@ -140,7 +130,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
// Same row geometry as the settings sidebar items. Shorter than the header
// strip so the hover highlight clears the strip's bottom separator.
minHeight: 28,
minHeight: density.tightRowHeight,
borderRadius: theme.borderRadius.lg,
},
rowHighlighted: {
@@ -152,17 +142,17 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 28,
minHeight: density.tightRowHeight,
paddingVertical: theme.spacing[1],
// Match the project rows' inner padding so the icons align on one vertical
// edge with the list below.
paddingHorizontal: theme.spacing[2],
},
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") },
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
label: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
@@ -14,14 +14,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DensityIcon } from "@/components/ui/density-icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -32,12 +32,10 @@ import { openExternalUrl } from "@/utils/open-external-url";
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const ThemedActivity = withUnistyles(Activity);
const ThemedCircleHelp = withUnistyles(CircleHelp);
const ThemedGift = withUnistyles(Gift);
const ThemedKeyboard = withUnistyles(Keyboard);
const ThemedDiscordIcon = withUnistyles(DiscordIcon);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -79,7 +77,6 @@ function HostVersionHint({ host }: { host: HostProfile }) {
}
export function SidebarHelpMenu() {
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
@@ -106,15 +103,16 @@ export function SidebarHelpMenu() {
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger(isCompact)}
style={styles.trigger}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered }) => (
<ThemedCircleHelp
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
<DensityIcon
icon={CircleHelp}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/>
)}
</DropdownMenuTrigger>
@@ -184,14 +182,14 @@ export function SidebarHelpMenu() {
}
const styles = StyleSheet.create((theme) => ({
trigger: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
trigger: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
}),
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
@@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
// Themed icon wrappers
const foregroundMutedColorMapping = (theme: Theme) => ({
const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
color: theme.colors.foregroundMuted,
});
}));
// One mapping per bucket, resolved through the status-dot producer so a group header and
// the rows under it cannot disagree about what "failed" looks like.
const needsInputColorMapping = (theme: Theme) => ({
const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
});
const failedColorMapping = (theme: Theme) => ({
}));
const failedGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
});
const attentionColorMapping = (theme: Theme) => ({
}));
const attentionGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
});
const runningColorMapping = (theme: Theme) => ({
}));
const runningGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
});
}));
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
@@ -470,23 +470,23 @@ function StatusGroupLeadingVisual({
return <StatusGroupIcon bucket={leading.bucket} />;
}
if (collapsed) {
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
return <ThemedChevronRight uniProps={mutedGroupIconProps} />;
}
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
return <ThemedChevronDown uniProps={mutedGroupIconProps} />;
}
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
switch (bucket) {
case "needs_input":
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />;
return <ThemedCircleAlert uniProps={needsInputGroupIconProps} />;
case "failed":
return <ThemedCircleX size={14} uniProps={failedColorMapping} />;
return <ThemedCircleX uniProps={failedGroupIconProps} />;
case "attention":
return <ThemedCircleCheck size={14} uniProps={attentionColorMapping} />;
return <ThemedCircleCheck uniProps={attentionGroupIconProps} />;
case "running":
return <ThemedCircleDot size={14} uniProps={runningColorMapping} />;
return <ThemedCircleDot uniProps={runningGroupIconProps} />;
case "done":
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
return <ThemedCircleCheck uniProps={mutedGroupIconProps} />;
}
}
@@ -1058,7 +1058,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusWorkspaceListContainer: {},
statusGroupRow: {
minHeight: 36,
minHeight: density.rowHeight,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -1084,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
width: densityIconBox("md"),
height: densityIconBox("md"),
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -1108,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
minHeight: 36,
minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -16,6 +16,7 @@ import {
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -27,19 +28,16 @@ import {
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
import { DensityIcon } from "@/components/ui/density-icon";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({
color: theme.colors.surface0,
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
});
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
export function SidebarWorkspaceRowFrame({
workspace,
@@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({
) : null;
}
let KindIcon: typeof ThemedMonitor;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = ThemedFolder;
let KindIcon: typeof Monitor;
if (workspaceKind === "local_checkout") KindIcon = Monitor;
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
else KindIcon = Folder;
const dotColorStyle = getStatusDotColorStyle(bucket);
return (
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
<DensityIcon icon={KindIcon} size="sm" color="foregroundMuted" />
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
</View>
);
@@ -482,8 +480,8 @@ const styles = StyleSheet.create((theme) => ({
},
workspaceStatusDot: {
position: "relative",
width: theme.iconSize.md,
height: 20,
width: densityIconBox("md"),
height: density.rowLineHeight,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -515,9 +513,9 @@ const styles = StyleSheet.create((theme) => ({
// to the meta row, so it takes the full width the trailing slot leaves behind.
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
fontWeight: "400",
lineHeight: 20,
lineHeight: density.rowLineHeight,
opacity: 0.76,
flex: 1,
minWidth: 0,
@@ -4,6 +4,7 @@ import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -485,7 +486,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
minHeight: 36,
minHeight: density.rowHeight,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -160,3 +160,16 @@ describe("combobox above-search ordering", () => {
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
});
});
describe("search-only option text", () => {
it("finds hidden search text while keeping title matches first", () => {
const bodyMatch = { id: "pr:7", label: "Fix parsing", searchText: "Release notes for 2024" };
const titleMatch = { id: "pr:8", label: "2024 release" };
expect(filterAndRankComboboxOptions([bodyMatch, titleMatch], "2024")).toEqual([
titleMatch,
bodyMatch,
]);
expect(filterAndRankComboboxOptions([bodyMatch], "unrelated")).toEqual([]);
expect(filterAndRankComboboxOptions([bodyMatch], "parsing 2024")).toEqual([bodyMatch]);
});
});
@@ -10,18 +10,22 @@ export interface ComboboxOptionModel {
id: string;
label: string;
description?: string;
/** Additional text to match without displaying it in the option row. */
searchText?: string;
kind?: ComboboxOptionKind;
}
const DESCRIPTION_FALLBACK_TIER = 99;
const SECONDARY_TEXT_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
const best = scoreTextFields(search, [opt.label, opt.id]);
if (best) return best;
if (!opt.description) return null;
const descriptionScore = scoreTextFields(search, [opt.description]);
if (!descriptionScore) return null;
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
const secondaryFields = opt.searchText
? [opt.label, opt.id, opt.description ?? "", opt.searchText]
: [opt.description ?? ""];
const secondaryScore = scoreTextFields(search, secondaryFields);
if (!secondaryScore) return null;
return { ...secondaryScore, tier: secondaryScore.tier + SECONDARY_TEXT_FALLBACK_TIER };
}
export interface BuildVisibleComboboxOptionsInput {
+55 -43
View File
@@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Check, File, Folder, Search } from "lucide-react-native";
@@ -50,6 +50,7 @@ import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
import {
@@ -948,9 +949,19 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
);
const { footer, safeAreaBottom } = props;
const frameStyle = useMemo(
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
[props.safeAreaBottom],
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
[footer, safeAreaBottom],
);
const footerStyle = useMemo(
() => [styles.footer, { paddingBottom: safeAreaBottom }],
[safeAreaBottom],
);
const footerView = useMemo(
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
[footer, footerStyle],
);
const body = props.hasChildren ? (
@@ -983,46 +994,47 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</View>
<SheetVisibleFrame footer={footerView}>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</View>
</SheetVisibleFrame>
</IsolatedBottomSheetModal>
);
}
@@ -0,0 +1,51 @@
import { useMemo, type ComponentType } from "react";
import { withUnistyles } from "react-native-unistyles";
import { densityIconSize, type DensityIconSize } from "@/styles/density";
import type { Theme } from "@/styles/theme";
type IconComponent = ComponentType<{ size: number; color: string; strokeWidth?: number }>;
type IconColor = {
[K in keyof Theme["colors"]]: Theme["colors"][K] extends string ? K : never;
}[keyof Theme["colors"]];
function IconGlyph({
icon: Icon,
size,
color,
strokeWidth,
}: {
icon: IconComponent;
size: number;
color: string;
strokeWidth?: number;
}) {
return <Icon size={size} color={color} strokeWidth={strokeWidth} />;
}
const ThemedIconGlyph = withUnistyles(IconGlyph);
/** An icon sized by density: larger on compact layouts. See `@/styles/density`. */
export function DensityIcon({
icon,
size,
sizeAdjustment = 0,
color,
strokeWidth,
}: {
icon: IconComponent;
size: DensityIconSize;
/** Only for a glyph that reads larger than others at the same size. */
sizeAdjustment?: number;
color: IconColor;
strokeWidth?: number;
}) {
const uniProps = useMemo(
() => (theme: Theme, rt: { breakpoint?: string }) => ({
size: densityIconSize(rt, size) + sizeAdjustment,
color: theme.colors[color],
}),
[size, sizeAdjustment, color],
);
return <ThemedIconGlyph icon={icon} strokeWidth={strokeWidth} uniProps={uniProps} />;
}
@@ -1,3 +1,4 @@
import { createContext, useCallback, useContext } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
@@ -8,6 +9,8 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/
export type IconButtonChromeSize = "large" | "small";
const SMALL_ICON_BUTTON_SIZE = 20;
/** Width and height of a large icon button on compact layouts. */
export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32;
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
export interface IconButtonChromeState {
@@ -27,20 +30,53 @@ interface IconButtonChromeOptions {
state?: IconButtonChromeState;
compact?: boolean;
disabled?: boolean;
/** A circular hitbox and highlight, for buttons inside a pill-shaped container. */
round?: boolean;
style?: StyleProp<ViewStyle>;
}
const IconButtonChromeContext = createContext<"default" | "header-island">("default");
/** A container owns the chrome of its icon actions, including custom menu and badge triggers. */
export const IconButtonChromeProvider = IconButtonChromeContext.Provider;
/** The same state callback works with Pressable, TooltipTrigger, and DropdownMenuTrigger. */
export function useIconButtonChromeStyle({
size,
compact,
disabled,
round,
style,
active,
}: Omit<IconButtonChromeOptions, "state"> & { active?: boolean }) {
const scope = useContext(IconButtonChromeContext);
return useCallback(
(state: IconButtonChromeState) =>
iconButtonChromeStyle({
size: scope === "header-island" ? "large" : size,
compact,
disabled,
round: scope === "header-island" || round,
style,
state: { ...state, active: active || state.active },
}),
[scope, size, compact, disabled, round, style, active],
);
}
/** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */
export function iconButtonChromeStyle({
size,
state,
compact = false,
disabled = false,
round = false,
style,
}: IconButtonChromeOptions): StyleProp<ViewStyle> {
const highlighted = state?.active || state?.hovered || state?.pressed || state?.open;
return [
resolveIconButtonFrame(size, compact),
round ? styles.round : null,
style,
highlighted ? styles.highlighted : null,
disabled ? styles.disabled : null,
@@ -67,11 +103,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
const styles = StyleSheet.create((theme) => ({
large: {
width: {
xs: 32,
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: 32,
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
padding: 0,
@@ -104,6 +140,9 @@ const styles = StyleSheet.create((theme) => ({
outlineWidth: 0,
outlineColor: "transparent",
},
round: {
borderRadius: theme.borderRadius.full,
},
highlighted: {
backgroundColor: theme.colors.interactionHighlight,
},
@@ -152,3 +152,5 @@ export function useIsolatedBottomSheetVisibility({
handleSheetDismiss,
};
}
export { SheetVisibleFrame } from "./visible-frame";
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout";
describe("getBottomSheetVisibleContentHeight", () => {
it("stops subtracting the retained keyboard height after the keyboard hides", () => {
const layout = {
containerHeight: 874,
contentPosition: 88,
handleHeight: 24,
keyboardHeight: 344,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
});
});
// Below the lowest snap the frame must travel with the sheet, not shrink to the screen edge.
describe("sheet footer motion", () => {
it("keeps the frame height fixed while opening and panning below the lowest snap", () => {
const layout = {
containerHeight: 1000,
handleHeight: 24,
keyboardHeight: 0,
isKeyboardVisible: false,
lowestDetentPosition: 400,
};
const heights = [1000, 800, 600, 400].map((contentPosition) =>
getBottomSheetVisibleContentHeight({ ...layout, contentPosition }),
);
expect(heights).toEqual([576, 576, 576, 576]);
});
});
it("resizes above the lowest snap and never returns negative or unmeasured heights", () => {
const layout = {
containerHeight: 1000,
handleHeight: 24,
keyboardHeight: 0,
isKeyboardVisible: false,
lowestDetentPosition: 400,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100 })).toBe(876);
expect(
getBottomSheetVisibleContentHeight({
...layout,
contentPosition: 100,
keyboardHeight: 950,
isKeyboardVisible: true,
}),
).toBe(0);
expect(
getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, handleHeight: -999 }),
).toBe(0);
expect(
getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, containerHeight: -999 }),
).toBe(0);
});
@@ -0,0 +1,25 @@
interface BottomSheetVisibleContentHeightInput {
containerHeight: number;
contentPosition: number;
lowestDetentPosition?: number;
handleHeight: number;
keyboardHeight: number;
isKeyboardVisible: boolean;
}
export function getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
lowestDetentPosition,
handleHeight,
keyboardHeight,
isKeyboardVisible,
}: BottomSheetVisibleContentHeightInput): number {
"worklet";
if (containerHeight < 0 || handleHeight < 0) return 0;
const position = Math.min(contentPosition, lowestDetentPosition ?? contentPosition);
return Math.max(
0,
containerHeight - position - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
);
}
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { View, StyleSheet } from "react-native";
import { KEYBOARD_STATUS, useBottomSheetInternal } from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout";
/** Fixed-detent sheet content. The footer shares the sheet's translation below its lowest snap. */
export function SheetVisibleFrame({
children,
footer,
}: {
children: ReactNode;
footer?: ReactNode;
}) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
const keyboard = animatedKeyboardState.get();
return {
height: getBottomSheetVisibleContentHeight({
containerHeight,
handleHeight,
contentPosition: animatedPosition.get(),
lowestDetentPosition: animatedDetentsState.get().detents?.[0],
keyboardHeight: keyboard.heightWithinContainer,
isKeyboardVisible: keyboard.status === KEYBOARD_STATUS.SHOWN,
}),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.frame, visibleStyle]}>
<View style={styles.body}>{children}</View>
{footer}
</Animated.View>
);
}
const styles = StyleSheet.create({
frame: { minHeight: 0, overflow: "hidden" },
body: { flex: 1, minHeight: 0 },
});
@@ -16,6 +16,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import {
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
useIconButtonChromeStyle,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
@@ -60,6 +61,8 @@ interface ToolbarButtonCommonProps extends Omit<
tooltip?: ReactNode;
selected?: boolean;
compact?: boolean;
/** A circular highlight, for buttons inside a pill-shaped container. */
round?: boolean;
shortcut?: ShortcutKey[][] | null;
tooltipSide?: "left" | "right" | "top" | "bottom";
style?: StyleProp<ViewStyle>;
@@ -81,6 +84,7 @@ export function ToolbarButton({
tooltip: tooltipBody,
selected = false,
compact = false,
round = false,
shortcut,
tooltipSide = "bottom",
style,
@@ -90,17 +94,14 @@ export function ToolbarButton({
testID,
...props
}: ToolbarButtonProps) {
const buttonStyle = useMemo(
() => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) =>
iconButtonChromeStyle({
size: "small",
compact,
state: { ...state, active: selected },
disabled: Boolean(disabled),
style,
}),
[compact, disabled, selected, style],
);
const buttonStyle = useIconButtonChromeStyle({
size: "small",
compact,
active: selected,
disabled: Boolean(disabled),
round,
style,
});
const accessibilityState = useMemo(
() => ({ disabled: Boolean(disabled), selected }),
[disabled, selected],
@@ -298,7 +298,6 @@ export function CompactModelSheet({
onClose={close}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint={usesBottomSheet}
contentStyle={styles.sheetBody}
testID="agent-controls-model-sheet"
>
@@ -346,7 +345,6 @@ export function CompactModelSheet({
onClose={closeModelBrowser}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint
presentation="push"
contentStyle={styles.sheetBody}
testID="agent-controls-model-browser-sheet"
+6 -1
View File
@@ -33,13 +33,18 @@ export {
getIsElectronMac as getIsElectronRuntimeMac,
} from "./platform";
/** Compact layouts are the breakpoints below `md`. */
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
return breakpoint === "xs" || breakpoint === "sm";
}
/**
* Reactive hook — re-renders the component when the breakpoint changes.
* Always use this instead of reading UnistylesRuntime.breakpoint directly.
*/
export function useIsCompactFormFactor(): boolean {
const { rt } = useUnistyles();
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
return isCompactBreakpoint(rt.breakpoint);
}
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { formatShortcut } from "@/utils/format-shortcut";
import { keyboardEventToComboString } from "./shortcut-string";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
@@ -1026,6 +1027,25 @@ describe("unassigned shortcuts", () => {
});
describe("matching", () => {
it("dispatches a captured Dvorak binding on the same logical key", () => {
const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true });
const combo = keyboardEventToComboString(event as KeyboardEvent);
expect(combo).toBe("Cmd+B");
const bindings = buildEffectiveBindings({ "sidebar-toggle-left-mac-cmd-b": combo! });
expect(
resolveShortcut({ event, context: { isMac: true, isDesktop: true }, bindings }).match
?.action,
).toBe("sidebar.toggle.left");
expect(
resolveShortcut({
event: { key: "x", code: "KeyB", metaKey: true },
context: { isMac: true, isDesktop: true },
bindings,
}).match,
).toBeNull();
});
it("stops matching a shortcut the user unassigned", () => {
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
@@ -22,6 +22,45 @@ function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
}
describe("keyboardEventToComboString", () => {
it("captures the Dvorak character rather than its QWERTY position", () => {
expect(
keyboardEventToComboString(keyboardEvent({ key: "b", code: "KeyN", metaKey: true })),
).toBe("Cmd+B");
});
it("captures shifted Dvorak letters and punctuation at other physical positions", () => {
expect(
keyboardEventToComboString(
keyboardEvent({ key: "T", code: "KeyK", metaKey: true, shiftKey: true }),
),
).toBe("Shift+Cmd+T");
expect(
keyboardEventToComboString(keyboardEvent({ key: ";", code: "KeyZ", ctrlKey: true })),
).toBe("Ctrl+;");
expect(
keyboardEventToComboString(
keyboardEvent({ key: "?", code: "BracketLeft", ctrlKey: true, shiftKey: true }),
),
).toBe("Ctrl+Shift+/");
});
it("keeps named keys and rewritten Option characters capturable", () => {
expect(keyboardEventToComboString(keyboardEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe(
"ArrowLeft",
);
expect(
keyboardEventToComboString(keyboardEvent({ key: "†", code: "KeyT", altKey: true })),
).toBe("Alt+T");
});
it("does not record a named key that dispatch does not support", () => {
expect(
keyboardEventToComboString(
keyboardEvent({ key: "Enter", code: "NumpadEnter", ctrlKey: true }),
),
).toBeNull();
});
it("captures the minus key (regression: previously returned null)", () => {
expect(keyboardEventToComboString(keyboardEvent({ key: "-", code: "Minus" }))).toBe("-");
});
+10 -1
View File
@@ -266,7 +266,16 @@ export function keyboardEventToComboString(event: KeyboardEvent): string | null
if (event.shiftKey) parts.push("Shift");
if (event.metaKey) parts.push("Cmd");
const humanKey = CODE_TO_KEY[event.code];
// Capture the same logical character that dispatch matches. Keep the code
// fallback for named keys and Option combinations whose key is rewritten.
const eventKey = event.key.toLowerCase();
const logicalKey = Object.entries(KEY_MAP).find(
([, mapping]) =>
mapping.key?.length === 1 &&
(mapping.key.toLowerCase() === eventKey ||
(event.shiftKey && mapping.shiftedKey === event.key)),
)?.[0];
const humanKey = logicalKey ?? CODE_TO_KEY[event.code];
if (humanKey) {
parts.push(humanKey);
} else {
+36 -26
View File
@@ -7,9 +7,11 @@ import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { CompactHeader } from "@/components/headers/compact-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostFilter } from "@/components/hosts/host-filter";
import type { HostPickerHost } from "@/components/hosts/host-picker";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
@@ -87,19 +89,14 @@ function PluginHostFilter({
pluginId,
identity,
params,
serverIds,
hosts,
}: {
serverId: string;
pluginId: string;
identity: PluginSurfaceContributionIdentity;
params: PluginScreenParams;
serverIds: string[];
hosts: HostPickerHost[];
}) {
const allHosts = useHosts();
const hosts = useMemo(
() => allHosts.filter((host) => serverIds.includes(host.serverId)),
[allHosts, serverIds],
);
const selectHost = useCallback(
(nextServerId: string) => {
// A legacy row remembers its own host; a screen's host carries to all the plugin's items.
@@ -113,9 +110,6 @@ function PluginHostFilter({
},
[identity, params, pluginId],
);
const show = serverIds.length > 1 && hosts.length > 1;
if (!show) return null;
return (
<HostFilter
hosts={hosts}
@@ -155,11 +149,12 @@ export function PluginSurfaceScreen() {
[identity, plugin],
);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const contributionServerIds = useMemo(
() =>
identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [],
[identity, installations, pluginId],
);
// The hosts this contribution is installed on; the filter shows when there is a choice.
const filterHosts = useMemo(() => {
if (!identity) return [];
const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity);
return hosts.filter((candidate) => serverIds.includes(candidate.serverId));
}, [hosts, identity, installations, pluginId]);
const title = useMemo(
() =>
surface
@@ -187,18 +182,23 @@ export function PluginSurfaceScreen() {
),
[Icon, title],
);
const hostFilter = useMemo(
() =>
identity && filterHosts.length > 1 ? (
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
params={params}
hosts={filterHosts}
/>
) : null,
[filterHosts, identity, params, pluginId, serverId],
);
const headerRight = useMemo(
() => (
<>
{identity ? (
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
params={params}
serverIds={contributionServerIds}
/>
) : null}
{hostFilter}
<HeaderToggleButton
accessibilityLabel="Close plugin"
onPress={close}
@@ -211,12 +211,22 @@ export function PluginSurfaceScreen() {
</HeaderToggleButton>
</>
),
[close, contributionServerIds, identity, params, pluginId, serverId],
[close, hostFilter],
);
return (
<View style={styles.screen}>
<ScreenHeader left={headerLeft} right={headerRight} />
{compact ? (
<CompactHeader
navigation="back"
onBack={close}
title={title}
titleTestID="plugin-surface-title"
actions={hostFilter}
/>
) : (
<ScreenHeader left={headerLeft} right={headerRight} />
)}
<View style={styles.body}>
{plugin && surface && client ? (
<SurfaceErrorBoundary
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildVisibleComboboxOptions } from "@/components/ui/combobox-options";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import {
type BranchPickerDetail,
@@ -340,3 +341,48 @@ describe("defaultBasePickerItem", () => {
expect(defaultBasePickerItem({ currentBranch: null })).toBeNull();
});
});
describe("starting-ref search", () => {
it.each(["gitlab", "github"] as const)(
"keeps a %s change request returned for a body-only match",
(forge) => {
const item: ForgeSearchItem = {
...prItem,
forge,
number: 3896,
title: "fix(config): send environment credentials only to trusted hosts",
body: "This mirrors the fix shipped for CVE-2024-53858.",
headRefName: "8545-duo-test",
};
const { options } = buildPickerOptionData({
branchDetails: [],
prItems: [item],
baseItem: null,
});
const visible = buildVisibleComboboxOptions({
options,
searchQuery: "2024",
searchable: true,
allowCustomValue: false,
customValuePrefix: "Use",
});
expect(visible.map((option) => option.label)).toEqual([
`${forge === "gitlab" ? "!" : "#"}3896 fix(config): send environment credentials only to trusted hosts`,
]);
const selected = buildPickerOptionData({
branchDetails: [],
prItems: [],
baseItem: { kind: "github-pr", item },
});
expect(
buildVisibleComboboxOptions({
options: selected.options,
searchQuery: "2024",
searchable: true,
allowCustomValue: false,
customValuePrefix: "Use",
}),
).toEqual(visible);
},
);
});
@@ -264,7 +264,10 @@ export function buildPickerOptionData(input: {
itemById.set(id, { kind: "github-pr", item: pr });
const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0;
const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000);
timedOptions.push({ option: { id, label: formatPrLabel(pr) }, timestamp });
timedOptions.push({
option: { id, label: formatPrLabel(pr), searchText: pr.body ?? undefined },
timestamp,
});
}
const baseItem = input.baseItem;
@@ -279,7 +282,13 @@ export function buildPickerOptionData(input: {
const labeledItem = disambiguate(baseItem, timedOptions);
itemById.set(selectedOptionId, labeledItem);
timedOptions.push({
option: { id: selectedOptionId, label: pickerItemLabel(labeledItem) },
option: {
id: selectedOptionId,
label: pickerItemLabel(labeledItem),
...(labeledItem.kind === "github-pr"
? { searchText: labeledItem.item.body ?? undefined }
: {}),
},
// The base sorts first: it is what a workspace is created from unless you pick something.
timestamp: Number.POSITIVE_INFINITY,
});
@@ -31,8 +31,7 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
@@ -2368,7 +2367,6 @@ export function NewWorkspaceScreen({
},
});
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
const importSession = useImportSession({ serverId: selectedServerId });
const composer = isTerminalLaunch ? (
@@ -2431,7 +2429,7 @@ export function NewWorkspaceScreen({
);
return (
<FileDropZone style={styles.container}>
<ScreenHeader left={screenHeaderLeft} borderless />
<MenuHeader borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<NewWorkspaceLayout
@@ -0,0 +1,72 @@
import { useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CompactHeader } from "@/components/headers/compact-header";
import {
EXPLORER_TOGGLE_KEYS,
WorkspaceExplorerToggle,
} from "@/screens/workspace/workspace-explorer-toggle";
import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row";
export { MobileWorkspaceTabSwitcher } from "./tab-switcher";
interface CompactWorkspaceHeaderProps {
serverId: string;
isLoading: boolean;
title: string;
subtitle: string;
isSubtitleDistinct: boolean;
onToggleExplorer: () => void;
explorerToggleLabel: string;
explorerToggleAccessibilityState: { expanded: boolean };
/** Tab switcher and workspace menu, before the explorer toggle. */
children: ReactNode;
}
/** The compact header with the workspace's project row and controls. */
export function CompactWorkspaceHeader({
isLoading,
title,
subtitle,
isSubtitleDistinct,
onToggleExplorer,
explorerToggleLabel,
explorerToggleAccessibilityState,
children,
serverId,
}: CompactWorkspaceHeaderProps) {
const { t } = useTranslation();
const projectRow = useMemo(
() => (
<WorkspaceHeaderProjectRow
subtitle={subtitle}
isSubtitleDistinct={isSubtitleDistinct}
serverId={serverId}
/>
),
[subtitle, isSubtitleDistinct, serverId],
);
const actions = (
<>
{children}
<WorkspaceExplorerToggle
onPress={onToggleExplorer}
label={explorerToggleLabel}
tooltipLabel={t("workspace.tabs.explorerSidebar.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
accessibilityState={explorerToggleAccessibilityState}
mobile
/>
</>
);
return (
<CompactHeader
navigation="menu"
title={title}
titleTestID="workspace-header-title"
subtitle={projectRow}
loading={isLoading}
actions={actions}
/>
);
}
@@ -0,0 +1,501 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { Keyboard, Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { SquarePen, SquareTerminal } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useIconButtonChromeStyle } from "@/components/ui/icon-button-chrome";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabOptionRow,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
import {
buildWorkspaceTabMenuEntries,
type WorkspaceTabMenuLabels,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
// The trigger draws at 32pt beside its island neighbours, so only vertical slop is free.
const TAB_SWITCHER_TRIGGER_HIT_SLOP = { top: 8, bottom: 8 } as const;
function getFallbackTabOptionLabel(
tab: WorkspaceTabDescriptor,
labels: {
newTab: string;
newAgent: string;
setup: string;
terminal: string;
browser: string;
agent: string;
changes: string;
files: string;
pullRequest: string;
},
): string {
if (tab.target.kind === "new_tab") {
return labels.newTab;
}
if (tab.target.kind === "draft") {
return labels.newAgent;
}
if (tab.target.kind === "setup") {
return labels.setup;
}
if (tab.target.kind === "terminal") {
return labels.terminal;
}
if (tab.target.kind === "browser") {
return labels.browser;
}
if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
}
if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") {
return labels.changes;
}
if (tab.target.kind === "files") {
return labels.files;
}
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
return labels.agent;
}
function getFallbackTabOptionDescription(
tab: WorkspaceTabDescriptor,
labels: {
newTab: string;
newAgent: string;
workspaceSetup: string;
agent: string;
terminal: string;
browser: string;
changes: string;
files: string;
pullRequest: string;
},
): string {
if (tab.target.kind === "new_tab") {
return labels.newTab;
}
if (tab.target.kind === "draft") {
return labels.newAgent;
}
if (tab.target.kind === "setup") {
return labels.workspaceSetup;
}
if (tab.target.kind === "agent") {
return labels.agent;
}
if (tab.target.kind === "terminal") {
return labels.terminal;
}
if (tab.target.kind === "browser") {
return labels.browser;
}
if (tab.target.kind === "provider_subagent") {
return labels.agent;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") {
return labels.changes;
}
if (tab.target.kind === "files") {
return labels.files;
}
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
if (tab.target.kind === "plugin") {
return tab.target.panelId;
}
return tab.target.path;
}
interface MobileWorkspaceTabSwitcherProps {
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
onSelectSwitcherTab: (key: string) => void;
createTerminalDisabled: boolean;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}
function MobileWorkspaceTabOption({
tab,
tabIndex,
tabCount,
normalizedServerId,
normalizedWorkspaceId,
selected,
active,
onPress,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
}: {
tab: WorkspaceTabDescriptor;
tabIndex: number;
tabCount: number;
normalizedServerId: string;
normalizedWorkspaceId: string;
selected: boolean;
active: boolean;
onPress: () => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}) {
const { t } = useTranslation();
const tabMenuLabels = useMemo<WorkspaceTabMenuLabels>(
() => ({
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
rename: t("workspace.tabs.menu.rename"),
closeAbove: t("workspace.tabs.menu.closeAbove"),
closeBelow: t("workspace.tabs.menu.closeBelow"),
closeLeft: t("workspace.tabs.menu.closeLeft"),
closeRight: t("workspace.tabs.menu.closeRight"),
closeOthers: t("workspace.tabs.menu.closeOthers"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"),
close: t("workspace.tabs.menu.close"),
}),
[t],
);
const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`;
const menuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile",
tab,
index: tabIndex,
tabCount,
menuTestIDBase,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsBefore: onCloseTabsAbove,
onCloseTabsAfter: onCloseTabsBelow,
onCloseOtherTabs,
labels: tabMenuLabels,
});
const fallbackLabels = useMemo(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}),
[t],
);
const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels);
const trailingAccessory = useMemo(
() => (
<MobileTabTrailingAccessory
menuTestIDBase={menuTestIDBase}
presentationLabel={fallbackLabel}
menuEntries={menuEntries}
/>
),
[menuTestIDBase, fallbackLabel, menuEntries],
);
const renderPresentation = useCallback(
(presentation: WorkspaceTabPresentation) => (
<WorkspaceTabOptionRow
presentation={presentation}
selected={selected}
active={active}
onPress={onPress}
trailingAccessory={trailingAccessory}
/>
),
[selected, active, onPress, trailingAccessory],
);
return (
<WorkspaceTabPresentationResolver
tab={tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{renderPresentation}
</WorkspaceTabPresentationResolver>
);
}
export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
tabs,
activeTabKey,
normalizedServerId,
normalizedWorkspaceId,
onSelectSwitcherTab,
createTerminalDisabled,
onCreateDraftTab,
onCreateTerminal,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
}: MobileWorkspaceTabSwitcherProps) {
const { t } = useTranslation();
const tabFallbackLabels = useMemo(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"),
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}),
[t],
);
const tabSwitcherOptions = useMemo(
() =>
tabs.map((tab) => ({
id: tab.key,
label: getFallbackTabOptionLabel(tab, tabFallbackLabels),
description: getFallbackTabOptionDescription(tab, tabFallbackLabels),
})),
[tabFallbackLabels, tabs],
);
const tabByKey = useMemo(() => new Map(tabs.map((tab) => [tab.key, tab])), [tabs]);
const triggerStyle = useIconButtonChromeStyle({ size: "large" });
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const tabIndexByKey = useMemo(() => {
const map = new Map<string, number>();
tabs.forEach((tab, index) => {
map.set(tab.key, index);
});
return map;
}, [tabs]);
const handleOpenSwitcher = useCallback(() => {
Keyboard.dismiss();
setIsOpen(true);
}, []);
const handleNewAgent = useCallback(() => {
setIsOpen(false);
onCreateDraftTab();
}, [onCreateDraftTab]);
const handleNewTerminal = useCallback(() => {
setIsOpen(false);
onCreateTerminal();
}, [onCreateTerminal]);
const footer = useMemo(
() => (
<View style={styles.tabSwitcherFooter}>
<Button
testID="workspace-tab-switcher-new-agent"
style={styles.tabSwitcherFooterButton}
variant="secondary"
leftIcon={SquarePen}
onPress={handleNewAgent}
>
{t("workspace.header.actions.newAgent")}
</Button>
<Button
testID="workspace-tab-switcher-new-terminal"
style={styles.tabSwitcherFooterButton}
variant="secondary"
leftIcon={SquareTerminal}
disabled={createTerminalDisabled}
onPress={handleNewTerminal}
>
{t("workspace.header.actions.newTerminal")}
</Button>
</View>
),
[createTerminalDisabled, handleNewAgent, handleNewTerminal, t],
);
const renderTabOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const tab = tabByKey.get(option.id);
if (!tab) {
return <View />;
}
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
if (tabIndex < 0) {
return <View />;
}
return (
<MobileWorkspaceTabOption
tab={tab}
tabIndex={tabIndex}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
selected={selected}
active={active}
onPress={onPress}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTab={onCloseTab}
onCloseTabsAbove={onCloseTabsAbove}
onCloseTabsBelow={onCloseTabsBelow}
onCloseOtherTabs={onCloseOtherTabs}
/>
);
},
[
tabByKey,
tabIndexByKey,
tabs.length,
normalizedServerId,
normalizedWorkspaceId,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
],
);
return (
<>
<Pressable
ref={anchorRef}
testID="workspace-tab-switcher-trigger"
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.switcher.trigger", { count: tabs.length })}
hitSlop={TAB_SWITCHER_TRIGGER_HIT_SLOP}
style={triggerStyle}
onPress={handleOpenSwitcher}
>
<View style={styles.tabCountBadge}>
<Text style={styles.tabCountText}>{tabs.length}</Text>
</View>
</Pressable>
<Combobox
options={tabSwitcherOptions}
value={activeTabKey}
onSelect={onSelectSwitcherTab}
searchable={false}
title={t("workspace.tabs.switcher.title")}
searchPlaceholder={t("workspace.tabs.switcher.searchPlaceholder")}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={anchorRef}
renderOption={renderTabOption}
footer={footer}
/>
</>
);
});
const styles = StyleSheet.create((theme) => ({
tabSwitcherFooter: {
flexDirection: "row",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[3],
},
tabSwitcherFooterButton: {
flex: 1,
},
// Sized and stroked like the 16px header glyphs beside it (1.5 stroke on a 24 viewbox = 1px).
tabCountBadge: {
minWidth: 16,
height: 16,
paddingHorizontal: 2,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.sm,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.foregroundMuted,
},
// Android pads text above the ascender by default, which pushes the digit below center.
tabCountText: {
fontSize: 10,
lineHeight: 12,
includeFontPadding: false,
textAlign: "center",
textAlignVertical: "center",
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
fontVariant: ["tabular-nums"],
},
}));
@@ -11,6 +11,8 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedPanelRight = withUnistyles(PanelRight);
export const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"];
interface WorkspaceExplorerToggleProps {
onPress: () => void;
label: string;
@@ -2,14 +2,7 @@ import React, { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useRouter, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import {
Copy,
Ellipsis,
Globe,
Import as ImportIcon,
Settings,
SquarePen,
} from "lucide-react-native";
import { Copy, Ellipsis, Globe, Import as ImportIcon, Settings } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
@@ -24,6 +17,7 @@ import {
extraMutedIconColorMapping,
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
useIconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
@@ -36,16 +30,13 @@ import type { Theme } from "@/styles/theme";
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedCopy = withUnistyles(Copy);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedGlobe = withUnistyles(Globe);
const ThemedImport = withUnistyles(ImportIcon);
const ThemedSettings = withUnistyles(Settings);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <TerminalProfileIcon iconKey={undefined} size={16} />;
const MENU_IMPORT_ICON = <ThemedImport size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
@@ -203,28 +194,25 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace
normalizedServerId: string;
showCreateBrowserTab: boolean;
createTerminalDisabled: boolean;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
onCreateBrowser: () => void;
}
/**
* Compact layouts have no tab strip to launch from, so new tabs live here alongside the workspace
* actions.
* Compact layouts have no tab strip. New agent and New terminal sit in the tab switcher's footer;
* the rarer launches stay here alongside the workspace actions.
*/
export function WorkspaceHeaderMenuMobile({
normalizedServerId,
showCreateBrowserTab,
createTerminalDisabled,
onCreateDraftTab,
onCreateTerminal,
onCreateTerminalWithProfile,
onCreateBrowser,
...workspaceActions
}: WorkspaceHeaderMenuMobileProps) {
const { t } = useTranslation();
const router = useRouter();
const triggerStyle = useIconButtonChromeStyle({ size: "large" });
const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
@@ -239,7 +227,7 @@ export function WorkspaceHeaderMenuMobile({
<DropdownMenu compactMode="sheet">
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={workspaceHeaderMenuButtonStyle}
style={triggerStyle}
hitSlop={COMPACT_HEADER_BUTTON_HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
@@ -252,13 +240,6 @@ export function WorkspaceHeaderMenuMobile({
testID="workspace-header-menu"
sheetTitle={t("workspace.header.actions.workspaceActions")}
>
<DropdownMenuItem
testID="workspace-header-new-agent"
leading={MENU_NEW_AGENT_ICON}
onSelect={onCreateDraftTab}
>
{t("workspace.header.actions.newAgent")}
</DropdownMenuItem>
{showCreateBrowserTab ? (
<DropdownMenuItem
testID="workspace-header-new-browser"
@@ -271,14 +252,6 @@ export function WorkspaceHeaderMenuMobile({
<WorkspaceHeaderWorkspaceActionItems {...workspaceActions} />
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={MENU_NEW_TERMINAL_ICON}
disabled={createTerminalDisabled}
onSelect={onCreateTerminal}
>
{t("workspace.header.actions.newTerminal")}
</DropdownMenuItem>
{profiles.map((profile) => (
<HeaderMenuProfileItem
key={profile.id}
@@ -0,0 +1,68 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HostBadge } from "@/hosts/host-badge";
import { useHostBadges } from "@/hosts/use-host-badges";
/**
* Which project the workspace belongs to, and which machine it runs on.
*
* Compact gets both, on their own line under the workspace name: this header is the only thing on
* screen that says where the workspace lives, because the sidebar that normally carries the host
* badge is closed. It still follows the host's own badge setting, so a purely local setup stays
* quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit
* side by side, and kept on compact, where the line exists for the host anyway.
*/
export function WorkspaceHeaderProjectRow({
subtitle,
isSubtitleDistinct,
serverId,
}: {
subtitle: string;
isSubtitleDistinct: boolean;
serverId: string;
}) {
const isCompact = useIsCompactFormFactor();
const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null;
const showProject = isSubtitleDistinct || isCompact;
if (!showProject && !hostBadge) {
return null;
}
return (
<View style={styles.row}>
{showProject ? (
<Text testID="workspace-header-subtitle" style={styles.project} numberOfLines={1}>
{subtitle}
</Text>
) : null}
{showProject && hostBadge ? <Text style={styles.separator}>·</Text> : null}
{hostBadge ? <HostBadge badge={hostBadge} /> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
// The compact header stretches this row to its available title width. Within that width,
// the project truncates and HostBadge yields its label while retaining its fixed icon.
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
minWidth: 0,
flexShrink: 1,
},
project: {
color: theme.colors.foregroundMuted,
fontSize: {
xs: theme.fontSize.sm,
md: theme.fontSize.base,
},
flexShrink: 1,
minWidth: 0,
},
separator: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
flexShrink: 0,
},
}));
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import { isCompactBreakpoint } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
/**
* Density sizes for surfaces that opt into touch sizing: rows, icons, and text grow on compact
* layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay
* desktop-sized everywhere, so a surface migrates by swapping its sizes for these.
*
* Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes
* are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping.
*/
export type DensityIconSize = keyof typeof ICON_SIZE;
type TextSize = "sm" | "base";
const COMPACT_ICON_SIZE: Record<DensityIconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
const COMPACT_TEXT_SIZE: Record<TextSize, keyof Theme["fontSize"]> = { sm: "base", base: "lg" };
// Compact layouts end where `md` begins; see `isCompactBreakpoint`.
function byLayout<T>(compact: T, desktop: T) {
return { xs: compact, md: desktop };
}
export const density = {
/** A list row such as a sidebar entry. */
rowHeight: byLayout(44, 36),
/** A row in a tight group, such as the sidebar's navigation rows. */
tightRowHeight: byLayout(40, 28),
/** The line height of a row's `base` text; slots that align to its first line share it. */
rowLineHeight: byLayout(22, 20),
/** A square icon-only button that sits in a row or toolbar. */
iconButton: byLayout(44, 28),
};
/** 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 densityFontSize(theme: Theme, size: TextSize) {
return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]);
}
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
}
/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */
export function densityIconProps<P extends object>(
size: DensityIconSize,
mapping: (theme: Theme) => P,
) {
return (theme: Theme, rt: { breakpoint?: string }) => ({
...mapping(theme),
size: densityIconSize(rt, size),
});
}
+3 -3
View File
@@ -68,9 +68,9 @@ describe("Pure black theme", () => {
});
describe("Sidebar interaction surfaces", () => {
it("keeps Light selection distinct from the sidebar surface", () => {
expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface1);
expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface3);
it("keeps Light hover and selection darker than the sidebar surface", () => {
expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3);
expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4);
expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar);
});
+2 -2
View File
@@ -267,8 +267,8 @@ export function buildLightSemanticColors(tint: LightThemeConfig) {
surface4: tint.surface4,
surfaceDiffEmpty: tint.surfaceDiffEmpty,
surfaceSidebar: tint.surfaceSidebar,
surfaceSidebarHover: tint.surface1,
surfaceSidebarSelected: tint.surface3,
surfaceSidebarHover: tint.surface3,
surfaceSidebarSelected: tint.surface4,
surfaceWorkspace: tint.surface0,
interactionHighlight: "rgba(0, 0, 0, 0.06)",
+16 -5
View File
@@ -31,7 +31,7 @@ export function UsageControls({
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
return (
<View style={styles.controls}>
{hostSelection && hostSelection.hosts.length > 1 ? (
{hasHostChoice(hostSelection) ? (
<HostFilter
hosts={hostSelection.hosts}
selectedHost={hostSelection.serverId}
@@ -51,6 +51,12 @@ export function UsageControls({
);
}
function hasHostChoice(
hostSelection: UsageHostSelection | undefined,
): hostSelection is UsageHostSelection {
return hostSelection !== undefined && hostSelection.hosts.length > 1;
}
function usageHostOptionTestID(serverId: string): string {
return `usage-host-filter-item-${serverId}`;
}
@@ -65,17 +71,22 @@ const styles = StyleSheet.create((theme) => ({
}));
/**
* One host's usage and the title-row controls that go with it, for the Usage modal.
* One host’s usage and title-row controls for the Usage modal.
* Returns null when there is nothing to control, so headers leave no empty island.
*/
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
view: UsageView;
refresh: () => void;
controls: ReactElement;
controls: ReactElement | null;
} {
const { view, refresh } = useHostUsage(hostSelection.serverId);
const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable";
const controls = useMemo(
() => <UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />,
[hostSelection, refresh, view],
() =>
hasControls ? (
<UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />
) : null,
[hasControls, hostSelection, refresh, view],
);
return { view, refresh, controls };
}
+4 -1
View File
@@ -9,7 +9,10 @@ import { usageCopy } from "./copy";
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
/** Refreshes every report on the host; spins while a refresh is in flight. */
/**
* Refreshes every report on the host; spins while a refresh is in flight. On compact it sits in the
* header's island, so its highlight is round.
*/
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
@@ -48,6 +48,74 @@ describe("findActiveFileMention", () => {
});
expect(mention).toBeNull();
});
it("returns null for the issue #1364 angle-bracket email example", () => {
const text = "user <12345+user@noreply.example.com>";
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
});
expect(mention).toBeNull();
});
it("returns null for email addresses (word char before @)", () => {
const text = "alice@example.com";
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
});
expect(mention).toBeNull();
});
it("returns null for email addresses (+ immediately before @)", () => {
// Local part ends with + so the character at index-1 is '+', not a word char.
const text = "noreply+@example.com";
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
});
expect(mention).toBeNull();
});
it("returns null for plus-tagged email local parts", () => {
const text = "noreply+tag@example.com";
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
});
expect(mention).toBeNull();
});
it("returns null when query contains angle brackets", () => {
const text = "check @foo<bar> end";
const mention = findActiveFileMention({
text,
cursorIndex: text.indexOf(" end"),
});
expect(mention).toBeNull();
});
it("returns null when query ends with a closing angle bracket", () => {
const text = "check @foo>";
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
});
expect(mention).toBeNull();
});
it("still detects a file mention after an email in the same input", () => {
const text = "user <12345+user@noreply.example.com> and also @file";
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
});
expect(mention).toEqual({
start: text.lastIndexOf("@"),
end: text.length,
query: "file",
});
});
});
describe("formatQuotedFileMentionPath", () => {
@@ -15,7 +15,7 @@ interface ApplyFileMentionReplacementInput {
relativePath: string;
}
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/;
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"'<>]/;
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length));
@@ -26,6 +26,11 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe
atIndex >= 0;
atIndex = atIndex === 0 ? -1 : beforeCursor.lastIndexOf("@", atIndex - 1)
) {
// Skip @ that looks like part of an email address (local-part char immediately before @).
if (atIndex > 0 && /[\w+]/.test(beforeCursor[atIndex - 1]!)) {
continue;
}
const query = beforeCursor.slice(atIndex + 1);
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
continue;
@@ -174,9 +174,6 @@ export function WorkspaceLabelManagerModal({
onClose={onClose}
header={header}
footer={footer}
// Bound the compact scroller to the live snap height, so the edit view's footer stays on
// screen instead of being pushed past the bottom of the sheet.
sizeContentToCurrentSnapPoint
testID="workspace-label-manager"
>
{draft && editing ? (
+67
View File
@@ -0,0 +1,67 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
const appDir = fileURLToPath(new URL(".", import.meta.url));
const rootDir = path.resolve(appDir, "../..");
const require = createRequire(import.meta.url);
const vitestEntry = path.join(path.dirname(require.resolve("vitest/package.json")), "vitest.mjs");
const tsgoEntry = path.join(
path.dirname(require.resolve("@typescript/native-preview/package.json")),
"bin/tsgo.js",
);
it("the test configuration typechecks with Expo's generated environment declaration", () => {
const fixtureDir = mkdtempSync(path.join(appDir, ".vitest-expo-env-"));
try {
writeFileSync(path.join(fixtureDir, "expo-env.d.ts"), '/// <reference types="expo/types" />\n');
writeFileSync(
path.join(fixtureDir, "tsconfig.json"),
JSON.stringify({
extends: "../tsconfig.json",
files: ["../vitest.config.ts", "expo-env.d.ts"],
include: [],
}),
);
const result = spawnSync(
process.execPath,
[tsgoEntry, "--noEmit", "--project", path.join(fixtureDir, "tsconfig.json")],
{ cwd: appDir, encoding: "utf8", timeout: 30_000 },
);
expect(result.error).toBeUndefined();
expect(result.status, result.stdout + result.stderr).toBe(0);
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
}, 40_000);
it.each([
{
name: "root",
cwd: rootDir,
args: ["packages/app/src/screens/workspace/use-mounted-tab-set.test.ts"],
},
{
name: "app",
cwd: appDir,
args: ["--project", "unit", "src/screens/workspace/use-mounted-tab-set.test.ts"],
},
])(
"$name tests can render React when the shell exports NODE_ENV=production",
({ cwd, args }) => {
const result = spawnSync(process.execPath, [vitestEntry, "run", ...args], {
cwd,
env: { ...process.env, NODE_ENV: "production" },
encoding: "utf8",
timeout: 30_000,
});
expect(result.error).toBeUndefined();
expect(result.status, result.stdout + result.stderr).toBe(0);
},
40_000,
);
+4
View File
@@ -3,6 +3,9 @@ import { playwright } from "@vitest/browser-playwright";
import path from "path";
import fs from "fs";
// Set this before Vite resolves dependencies and Vitest starts its workers.
Object.assign(process.env, { NODE_ENV: "test" });
const appNodeModules = path.resolve(__dirname, "node_modules");
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
const resolvePackageEntry = (packageName: string) => {
@@ -26,6 +29,7 @@ export default defineConfig({
"src/**/*.{test,spec}.{ts,tsx}",
"native-release-version.test.ts",
"app.config.test.ts",
"vitest-environment.test.ts",
],
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js";
function collect(items: readonly AgentTimelineItem[], turnId?: string): string {
let output = "";
const writer = createFollowTranscriptWriter((chunk) => {
output += chunk;
});
for (const item of items) {
writer.push(item, turnId);
}
writer.end();
return output;
}
describe("createFollowTranscriptWriter", () => {
it("matches one-shot logs when reasoning and assistant text alternate", () => {
const items: AgentTimelineItem[] = [
{ type: "reasoning", text: "Check " },
{ type: "reasoning", text: "the commands." },
{ type: "assistant_message", text: "I will list the commands.", messageId: "msg_1" },
{ type: "reasoning", text: "Then summarize." },
{ type: "assistant_message", text: "Done.", messageId: "msg_2" },
];
expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items));
});
it("keeps a word that arrives split across fragments intact", () => {
const output = collect([
{ type: "assistant_message", text: "the paseo-pr", messageId: "msg_1" },
{ type: "assistant_message", text: "-cycle label\n", messageId: "msg_1" },
]);
expect(output).toBe("the paseo-pr-cycle label\n");
});
it("streams text as it arrives without inventing a line break", () => {
const chunks: string[] = [];
const writer = createFollowTranscriptWriter((chunk) => {
chunks.push(chunk);
});
writer.push({
type: "assistant_message",
text: "first line",
messageId: "msg_1",
});
expect(chunks.join("")).toBe("first line");
writer.push({
type: "assistant_message",
text: "\nsecond",
messageId: "msg_1",
});
expect(chunks.join("")).toBe("first line\nsecond");
writer.push({
type: "assistant_message",
text: " line\n",
messageId: "msg_1",
});
expect(chunks.join("")).toBe("first line\nsecond line");
writer.end();
expect(chunks.join("")).toBe("first line\nsecond line\n");
});
it("emits the same transcript the one-shot command prints for the same items", () => {
const items: AgentTimelineItem[] = [
{
type: "assistant_message",
text: "Plan for paseo-pr",
messageId: "msg_1",
},
{
type: "assistant_message",
text: "-cycle:\n- build\n- test\n",
messageId: "msg_1",
},
{ type: "error", message: "boom" },
{ type: "assistant_message", text: "done", messageId: "msg_2" },
];
expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items));
});
it("starts a new message when the messageId changes", () => {
const output = collect([
{ type: "assistant_message", text: "first", messageId: "msg_1" },
{ type: "assistant_message", text: "second", messageId: "msg_2" },
]);
expect(output).toBe("first\nsecond\n");
});
it("does not merge fragments across turns", () => {
let output = "";
const writer = createFollowTranscriptWriter((chunk) => {
output += chunk;
});
writer.push({ type: "assistant_message", text: "first" }, "turn_1");
writer.push({ type: "assistant_message", text: "second" }, "turn_2");
writer.end();
expect(output).toBe("first\nsecond\n");
});
it("prints the thought prefix once for a merged reasoning stream", () => {
const output = collect([
{ type: "reasoning", text: "checking the" },
{ type: "reasoning", text: " lockfile\nthen the build\n" },
]);
expect(output).toBe("[Thought] checking the lockfile\nthen the build\n");
});
it("keeps reasoning blocks separate across hidden timeline items", () => {
let output = "";
const writer = createFollowTranscriptWriter((chunk) => {
output += chunk;
}, "reasoning");
writer.push({ type: "reasoning", text: "checking files" });
writer.push({
type: "tool_call",
callId: "tool_1",
name: "Bash",
status: "completed",
error: null,
detail: { type: "unknown", input: "ls", output: "files" },
});
writer.push({ type: "reasoning", text: "running tests" });
writer.end();
expect(output).toBe("[Thought] checking files\n[Thought] running tests\n");
});
it("keeps interior blank lines and drops trailing whitespace", () => {
const output = collect([
{
type: "assistant_message",
text: " # Title\n\nBody\n\n ",
messageId: "msg_1",
},
]);
expect(output).toBe("# Title\n\nBody\n");
});
});
+195 -18
View File
@@ -1,7 +1,7 @@
import { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import { waitForStop } from "../../utils/wait-for-stop.js";
import type { CommandOptions } from "../../output/index.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
@@ -32,12 +32,29 @@ export type AgentLogsResult = void;
export const NO_ACTIVITY_MESSAGE = "No activity to display.";
interface FetchAgentTimelineItemsOptions {
timeoutMs?: number;
sinceTimestampMs?: number;
}
interface FollowModeInput {
client: DaemonClient;
agentId: string;
options: AgentLogsOptions;
sinceTimestampMs: number | undefined;
}
export async function fetchAgentTimelineItems(
client: DaemonClient,
agentId: string,
options?: { timeoutMs?: number },
options?: FetchAgentTimelineItemsOptions,
): Promise<AgentTimelineItem[]> {
return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs });
return fetchProjectedTimelineItems({
client,
agentId,
timeoutMs: options?.timeoutMs,
sinceTimestampMs: options?.sinceTimestampMs,
});
}
export function formatAgentActivityTranscript(
@@ -53,6 +70,134 @@ export function formatAgentActivityTranscript(
);
}
type StreamingTextType = "assistant_message" | "reasoning";
const THOUGHT_PREFIX = "[Thought] ";
interface PendingStreamingText {
type: StreamingTextType;
turnId: string | undefined;
messageId: string | undefined;
buffer: string;
emitted: boolean;
}
export interface FollowTranscriptWriter {
push(item: AgentTimelineItem, turnId?: string): void;
end(): void;
}
function lastNonWhitespaceIndex(text: string): number {
for (let index = text.length - 1; index >= 0; index -= 1) {
if (!/\s/.test(text[index])) {
return index;
}
}
return -1;
}
/**
* Follow mode receives one streaming fragment per event, while the one-shot
* transcript sees the whole timeline and merges consecutive assistant and
* reasoning fragments with exact concatenation (`projectTimelineRows`). Printing
* each fragment on its own line splits words at fragment boundaries and
* reprints the reasoning prefix, so a chain of fragments is written as one
* stream instead: text goes out as it arrives, and only the trailing whitespace
* is held back so the message ends trimmed the way the transcript does.
*/
export function createFollowTranscriptWriter(
write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk),
filter?: string,
): FollowTranscriptWriter {
let pending: PendingStreamingText | null = null;
const emit = (chain: PendingStreamingText, text: string): void => {
if (!chain.emitted) {
chain.emitted = true;
if (chain.type === "reasoning") {
write(THOUGHT_PREFIX);
}
}
write(text);
};
const flushContent = (chain: PendingStreamingText): void => {
if (!chain.emitted) {
chain.buffer = chain.buffer.replace(/^\s+/, "");
}
const lastContent = lastNonWhitespaceIndex(chain.buffer);
if (lastContent < 0) {
return;
}
emit(chain, chain.buffer.slice(0, lastContent + 1));
chain.buffer = chain.buffer.slice(lastContent + 1);
};
const closePending = (): void => {
const chain = pending;
pending = null;
if (!chain) {
return;
}
const tail = chain.emitted ? chain.buffer.trimEnd() : chain.buffer.trim();
if (tail) {
emit(chain, tail);
}
if (chain.emitted) {
write("\n");
}
};
const continuesChain = (item: AgentTimelineItem, turnId: string | undefined): boolean => {
if (!pending || pending.type !== item.type || pending.turnId !== turnId) {
return false;
}
if (item.type !== "assistant_message") {
return true;
}
return item.messageId === undefined || item.messageId === pending.messageId;
};
const startChain = (
item: Extract<AgentTimelineItem, { type: StreamingTextType }>,
turnId: string | undefined,
): PendingStreamingText => {
closePending();
const chain: PendingStreamingText = {
type: item.type,
turnId,
messageId: item.type === "assistant_message" ? item.messageId : undefined,
buffer: "",
emitted: false,
};
pending = chain;
return chain;
};
return {
push(item, turnId) {
if (!matchesFilter(item, filter)) {
closePending();
return;
}
if (item.type === "assistant_message" || item.type === "reasoning") {
const chain = continuesChain(item, turnId) && pending ? pending : startChain(item, turnId);
chain.buffer += item.text;
flushContent(chain);
return;
}
closePending();
const transcript = formatAgentActivityTranscript([item]);
if (transcript && transcript !== NO_ACTIVITY_MESSAGE) {
write(`${transcript}\n`);
}
},
end() {
closePending();
},
};
}
function parseTailCount(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const parsed = Number.parseInt(raw, 10);
@@ -62,6 +207,19 @@ function parseTailCount(raw: string | undefined): number | undefined {
return parsed;
}
function parseSinceTimestamp(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const timestampMs = Date.parse(raw);
if (Number.isNaN(timestampMs)) {
throw {
code: "INVALID_TIMESTAMP",
message: `Invalid --since value: ${raw}`,
details: "Use a timestamp such as 2026-01-01T00:00:00Z.",
} satisfies CommandError;
}
return timestampMs;
}
/**
* Check if a timeline item matches the filter type
*/
@@ -98,6 +256,7 @@ export async function runLogsCommand(
process.exit(1);
}
const sinceTimestampMs = parseSinceTimestamp(options.since);
const client = await connectToDaemon({ target: options.daemonTarget });
try {
@@ -118,12 +277,19 @@ export async function runLogsCommand(
await client.close().catch(() => {});
process.exit(1);
}
await runFollowMode(client, resolvedId, options);
await runFollowMode({
client,
agentId: resolvedId,
options,
sinceTimestampMs,
});
return;
}
// Fetch timeline directly via cursor RPC.
let timelineItems = await fetchAgentTimelineItems(client, resolvedId);
let timelineItems = await fetchAgentTimelineItems(client, resolvedId, {
sinceTimestampMs,
});
// Apply filter
if (options.filter) {
@@ -159,11 +325,12 @@ export async function runLogsCommand(
/**
* Follow mode: stream logs in real-time until interrupted
*/
async function runFollowMode(
client: DaemonClient,
agentId: string,
options: AgentLogsOptions,
): Promise<void> {
async function runFollowMode({
client,
agentId,
options,
sinceTimestampMs,
}: FollowModeInput): Promise<void> {
const DEFAULT_FOLLOW_TAIL = 10;
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
@@ -172,6 +339,7 @@ async function runFollowMode(
try {
existingItems = await fetchAgentTimelineItems(client, agentId, {
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
sinceTimestampMs,
});
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
@@ -194,31 +362,39 @@ async function runFollowMode(
const tailLabel =
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
const writer = createFollowTranscriptWriter(undefined, options.filter);
const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => {
if (message.type === "agent.timeline.replacement") {
writer.end();
console.log("\n[Timeline replaced; earlier output is no longer current]");
return;
}
if (message.type === "agent.timeline.error") {
writer.end();
console.error(`Timeline observation stopped: ${message.payload.error}`);
return;
}
if (message.type === "agent.timeline.subscription_restored") {
writer.end();
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
return;
}
if (message.payload.event.type === "timeline") {
const item = message.payload.event.item;
// Apply filter
if (options.filter && !matchesFilter(item, options.filter)) {
const matchesSince =
sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs;
if (!matchesSince) {
return;
}
// Print each timeline item as it arrives using the curator format
const transcript = formatAgentActivityTranscript([item]);
if (transcript !== NO_ACTIVITY_MESSAGE) {
console.log(transcript);
}
const item = message.payload.event.item;
writer.push(item, message.payload.event.turnId);
} else if (
message.payload.event.type === "turn_completed" ||
message.payload.event.type === "turn_failed" ||
message.payload.event.type === "turn_canceled"
) {
writer.end();
}
});
@@ -226,6 +402,7 @@ async function runFollowMode(
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
await waitForStop();
writer.end();
unsubscribe();
await client.close();
}
+124 -91
View File
@@ -1,5 +1,8 @@
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
@@ -55,106 +58,136 @@ export interface PermitAllowOptions extends CommandOptions {
host?: string;
}
export async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command,
): Promise<PermitAllowResult> {
// No validation needed - if no reqId provided, allow all by default
export interface PermitClient {
fetchAgent(options: { agentId: string }): Promise<{
agent: { id: string; pendingPermissions?: AgentPermissionRequest[] };
} | null>;
respondToPermissionAndWait(
agentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<unknown>;
close(): Promise<void>;
}
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined;
if (options.input) {
try {
updatedInput = JSON.parse(options.input);
} catch (err) {
const error: CommandError = {
code: "INVALID_JSON",
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
};
throw error;
}
}
export type PermitClientConnector = (
options: Parameters<typeof connectToDaemon>[0],
) => Promise<PermitClient>;
const client = await connectToDaemon({ target: options.daemonTarget });
export function createAllowCommand(connect: PermitClientConnector = connectToDaemon) {
return async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command,
): Promise<PermitAllowResult> {
// No validation needed - if no reqId provided, allow all by default
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[];
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId));
if (!permission) {
await client.close();
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined;
if (options.input) {
try {
updatedInput = JSON.parse(options.input);
} catch (err) {
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
code: "INVALID_JSON",
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
};
throw error;
}
permissionsToAllow = [permission];
}
// Allow permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToAllow.map(async (permission) => {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
}),
);
const client = await connect({ target: options.daemonTarget });
await client.close();
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[];
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId));
if (!permission) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToAllow = [permission];
}
// Allow permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToAllow.map(async (permission) => {
try {
await client.respondToPermissionAndWait(resolvedAgentId, permission.id, {
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "PERMISSION_RESPONSE_UNCONFIRMED",
message: `Permission response unconfirmed for request ${permission.id}: ${message}`,
details: 'Use "paseo permit ls" to check whether the request is still pending',
};
throw error;
}
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
}),
);
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "ALLOW_PERMISSION_FAILED",
message: `Failed to allow permission: ${message}`,
};
throw error;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "ALLOW_PERMISSION_FAILED",
message: `Failed to allow permission: ${message}`,
};
throw error;
}
};
}
export const runAllowCommand = createAllowCommand();
+100 -83
View File
@@ -6,6 +6,7 @@ import {
permitResponseSchema,
toPermissionResponseItem,
type PermissionResponseItem,
type PermitClientConnector,
} from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>;
@@ -17,98 +18,114 @@ export interface PermitDenyOptions extends CommandOptions {
host?: string;
}
export async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command,
): Promise<PermitDenyResult> {
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: "MISSING_ARGUMENT",
message: "Request ID is required unless --all is specified",
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
export function createDenyCommand(connect: PermitClientConnector = connectToDaemon) {
return async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command,
): Promise<PermitDenyResult> {
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
code: "MISSING_ARGUMENT",
message: "Request ID is required unless --all is specified",
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
};
throw error;
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[];
if (options.all) {
permissionsToDeny = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
if (!permission) {
const client = await connect({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
permissionsToDeny = [permission];
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[];
if (options.all) {
permissionsToDeny = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!),
);
if (!permission) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToDeny = [permission];
}
// Deny permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToDeny.map(async (permission) => {
try {
await client.respondToPermissionAndWait(resolvedAgentId, permission.id, {
behavior: "deny",
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "PERMISSION_RESPONSE_UNCONFIRMED",
message: `Permission response unconfirmed for request ${permission.id}: ${message}`,
details: 'Use "paseo permit ls" to check whether the request is still pending',
};
throw error;
}
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
}),
);
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DENY_PERMISSION_FAILED",
message: `Failed to deny permission: ${message}`,
};
throw error;
}
// Deny permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToDeny.map(async (permission) => {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: "deny",
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
}),
);
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DENY_PERMISSION_FAILED",
message: `Failed to deny permission: ${message}`,
};
throw error;
}
};
}
export const runDenyCommand = createDenyCommand();
@@ -0,0 +1,177 @@
import { setImmediate as waitForCommandContinuations } from "node:timers/promises";
import { Command } from "commander";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import { createAllowCommand, type PermitClient } from "./allow.js";
import { createDenyCommand } from "./deny.js";
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
const requests: AgentPermissionRequest[] = [
{
id: "permission-write",
provider: "codex",
name: "CodexBash",
kind: "tool",
},
{ id: "permission-edit", provider: "codex", name: "CodexBash", kind: "tool" },
];
class PermissionClient implements PermitClient {
readonly pending = [...requests];
readonly responses = new Map<string, AgentPermissionResponse>();
closed = false;
private notifyReceived = () => {};
private readonly confirmations = new Map<
string,
{
resolve: () => void;
reject: (error: Error) => void;
}
>();
async fetchAgent() {
return { agent: { id: agentId, pendingPermissions: this.pending } };
}
async respondToPermission(
_agentId: string,
requestId: string,
response: AgentPermissionResponse,
) {
this.responses.set(requestId, response);
this.notifyReceived();
}
async respondToPermissionAndWait(
responseAgentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
const confirmation = new Promise<void>((resolve, reject) => {
this.confirmations.set(requestId, { resolve, reject });
});
await this.respondToPermission(responseAgentId, requestId, response);
await confirmation;
}
async close() {
this.closed = true;
}
async waitForResponses(count: number) {
while (this.responses.size < count) {
await new Promise<void>((resolve) => {
this.notifyReceived = resolve;
});
}
}
confirm(requestId: string) {
const confirmation = this.confirmations.get(requestId);
if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`);
this.pending.splice(
this.pending.findIndex((request) => request.id === requestId),
1,
);
confirmation.resolve();
}
fail(requestId: string, error: Error) {
const confirmation = this.confirmations.get(requestId);
if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`);
confirmation.reject(error);
}
}
const options = {
daemonTarget: { kind: "endpoint" as const, host: "test.invalid" },
};
const actions = [
{ name: "allow", create: createAllowCommand, result: "allowed" },
{ name: "deny", create: createDenyCommand, result: "denied" },
];
for (const action of actions) {
describe(`permit ${action.name}`, () => {
it("returns success and closes only after confirmation", async () => {
const client = new PermissionClient();
let completed = false;
const command = action.create(async () => client)(
agentId,
requests[0].id,
options,
new Command(),
);
void command.then(
() => (completed = true),
() => false,
);
await client.waitForResponses(1);
await waitForCommandContinuations();
expect(completed).toBe(false);
expect(client.closed).toBe(false);
expect(client.pending).toEqual(requests);
client.confirm(requests[0].id);
const result = await command;
expect(result.data).toEqual([
{
requestId: requests[0].id,
agentId,
agentShortId: agentId.slice(0, 7),
name: "CodexBash",
result: action.result,
},
]);
expect(client.closed).toBe(true);
expect(client.pending).toEqual([requests[1]]);
});
it("waits for every confirmation with --all", async () => {
const client = new PermissionClient();
let completed = false;
const command = action.create(async () => client)(
agentId,
undefined,
{ ...options, all: true },
new Command(),
);
void command.then(
() => (completed = true),
() => false,
);
await client.waitForResponses(2);
client.confirm(requests[0].id);
// Wait beyond the first reply's promise continuations while withholding the second.
await waitForCommandContinuations();
expect(completed).toBe(false);
expect(client.closed).toBe(false);
client.confirm(requests[1].id);
const result = await command;
expect(result.data.map((item) => item.result)).toEqual([action.result, action.result]);
expect(client.closed).toBe(true);
expect(client.pending).toEqual([]);
});
it("rejects an unconfirmed response without returning success", async () => {
const client = new PermissionClient();
const command = action.create(async () => client)(
agentId,
requests[0].id,
options,
new Command(),
);
const failure = expect(command).rejects.toMatchObject({
code: "PERMISSION_RESPONSE_UNCONFIRMED",
message: expect.stringContaining(requests[0].id),
details: 'Use "paseo permit ls" to check whether the request is still pending',
});
await client.waitForResponses(1);
client.fail(requests[0].id, new Error("Timeout waiting for message (15000ms)"));
await failure;
expect(client.closed).toBe(true);
expect(client.pending).toEqual(requests);
});
});
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { parseSafeInteger } from "../../utils/integer.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
@@ -441,8 +442,8 @@ function buildNewAgentConfigPatch(
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
const parsed = parseSafeInteger(value);
if (parsed === undefined || parsed <= 0) {
throw {
code: "INVALID_INTEGER",
message: `${flag} must be a positive integer`,
@@ -1,3 +1,4 @@
import { parseSafeInteger } from "../../utils/integer.js";
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
@@ -85,8 +86,8 @@ function parseLineNumber(flag: string, value?: string): number | undefined {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) {
const parsed = parseSafeInteger(value);
if (parsed === undefined) {
throw {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
@@ -2,6 +2,7 @@ import path from "path";
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { resolveRepositoryDirectory } from "./repository-directory.js";
import type {
CommandOptions,
SingleResult,
@@ -31,6 +32,7 @@ export const archiveSchema: OutputSchema<WorktreeArchiveResult> = {
export interface WorktreeArchiveOptions extends CommandOptions {
host?: string;
cwd?: string;
}
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>;
@@ -49,6 +51,7 @@ export async function runArchiveCommandWithDeps(
deps: { connectToDaemon: typeof connectToDaemon },
): Promise<WorktreeArchiveCommandResult> {
const host = getDaemonHost({ target: options.daemonTarget });
const cwd = resolveRepositoryDirectory(options);
// Validate arguments
if (!nameArg || nameArg.trim().length === 0) {
@@ -76,7 +79,7 @@ export async function runArchiveCommandWithDeps(
try {
// Get the list of worktrees first to resolve the name
const listResponse = await client.getPaseoWorktreeList({});
const listResponse = await client.getPaseoWorktreeList({ cwd });
if (listResponse.error) {
const error: CommandError = {
+6 -2
View File
@@ -9,7 +9,10 @@ export function createWorktreeCommand(): Command {
const worktree = new Command("worktree").description("Manage Paseo-managed git worktrees");
addJsonAndDaemonHostOptions(
worktree.command("ls").description("List Paseo-managed git worktrees"),
worktree
.command("ls")
.description("List Paseo-managed git worktrees")
.option("--cwd <path>", "Repository directory (default: current)"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
@@ -31,7 +34,8 @@ export function createWorktreeCommand(): Command {
worktree
.command("archive")
.description("Archive a worktree (removes worktree and associated branch)")
.argument("<name>", "Worktree name or branch name"),
.argument("<name>", "Worktree name or branch name")
.option("--cwd <path>", "Repository directory (default: current)"),
).action(withOutput(runArchiveCommand));
return worktree;
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { basename } from "node:path";
import { connectToDaemon } from "../../utils/client.js";
import { resolveRepositoryDirectory } from "./repository-directory.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Worktree list item for display */
@@ -40,12 +41,14 @@ export type WorktreeLsResult = ListResult<WorktreeListItem>;
export interface WorktreeLsOptions extends CommandOptions {
host?: string;
cwd?: string;
}
export async function runLsCommand(
options: WorktreeLsOptions,
_command: Command,
): Promise<WorktreeLsResult> {
const cwd = resolveRepositoryDirectory(options);
const client = await connectToDaemon({ target: options.daemonTarget });
try {
@@ -53,7 +56,7 @@ export async function runLsCommand(
const agents = agentsPayload.entries.map((entry) => entry.agent);
// Get worktree list from daemon
const response = await client.getPaseoWorktreeList({});
const response = await client.getPaseoWorktreeList({ cwd });
await client.close();
@@ -0,0 +1,13 @@
import { resolve } from "node:path";
import type { DaemonTarget } from "../../utils/daemon-target.js";
export function resolveRepositoryDirectory(options: {
cwd?: string;
daemonTarget: DaemonTarget;
}): string {
const cwd = options.cwd ?? process.cwd();
if (options.daemonTarget.kind === "instance") {
return resolve(cwd);
}
return cwd;
}
+20
View File
@@ -0,0 +1,20 @@
export function parseSafeInteger(value: string): number | undefined {
const input = value.trim();
const match = /^[+-]?(\d*)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(input);
if (!match) {
return undefined;
}
const digits = match[1] + (match[2] ?? "");
const parsed = Number(input);
if (digits.length === 0 || !Number.isSafeInteger(parsed)) {
return undefined;
}
// Check the decimal value before floating-point rounding can hide a fraction.
const decimalPlaces = (match[2]?.length ?? 0) - Number(match[3] ?? "0");
if (decimalPlaces > 0 && /[1-9]/.test(digits.slice(-decimalPlaces))) {
return undefined;
}
return parsed;
}
+7 -1
View File
@@ -7,6 +7,7 @@ interface FetchProjectedTimelineItemsInput {
client: DaemonClient;
agentId: string;
timeoutMs?: number;
sinceTimestampMs?: number;
}
export async function fetchProjectedTimelineItems(
@@ -18,5 +19,10 @@ export async function fetchProjectedTimelineItems(
projection: "projected",
timeout: input.timeoutMs,
});
return timeline.entries.map((entry) => entry.item);
let entries = timeline.entries;
const sinceTimestampMs = input.sinceTimestampMs;
if (sinceTimestampMs !== undefined) {
entries = entries.filter((entry) => Date.parse(entry.timestamp) >= sinceTimestampMs);
}
return entries.map((entry) => entry.item);
}
+68 -1
View File
@@ -21,7 +21,10 @@
import assert from "node:assert";
import { getAvailablePort } from "./helpers/network.ts";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { mkdtemp, rm, mkdir } from "fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
import { tmpdir } from "os";
import { join } from "path";
@@ -50,6 +53,7 @@ try {
const result = await $`npx paseo worktree ls --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "worktree ls --help should exit 0");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
console.log("✓ worktree ls --help shows options\n");
}
@@ -86,6 +90,7 @@ try {
const result = await $`npx paseo worktree archive --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "worktree archive --help should exit 0");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
assert(result.stdout.includes("<name>"), "help should mention required name argument");
console.log("✓ worktree archive --help shows options\n");
}
@@ -167,4 +172,66 @@ try {
await rm(paseoHome, { recursive: true, force: true });
}
// Exercise repository selection through the real CLI and daemon. An empty
// request used to fail before Git could inspect even a valid repository.
const daemon = await startTestDaemon();
const git = promisify(execFile);
try {
const repo = join(daemon.workDir, "repository");
await mkdir(repo);
await git("git", ["init", repo]);
await git("git", [
"-C",
repo,
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--allow-empty",
"-m",
"Initial commit",
]);
for (const args of [
["worktree", "ls", "--json"],
["worktree", "ls", "--cwd", repo, "--json"],
["worktree", "ls", "--cwd", "./repository", "--json"],
["worktree", "ls", "--host", `127.0.0.1:${daemon.port}`, "--cwd", ".", "--json"],
]) {
const explicit = args.includes("--cwd");
const result = await runPaseoCli(daemon, args, {
cwd: explicit ? daemon.workDir : repo,
env: { FORCE_COLOR: undefined },
});
assert.strictEqual(result.exitCode, 0, result.stderr);
assert.deepStrictEqual(JSON.parse(result.stdout), []);
}
for (const args of [
["worktree", "archive", "missing", "--json"],
["worktree", "archive", "missing", "--cwd", repo, "--json"],
["worktree", "archive", "missing", "--cwd", "./repository", "--json"],
[
"worktree",
"archive",
"missing",
"--host",
`127.0.0.1:${daemon.port}`,
"--cwd",
".",
"--json",
],
]) {
const result = await runPaseoCli(daemon, args, {
cwd: args.includes("--cwd") ? daemon.workDir : repo,
env: { FORCE_COLOR: undefined },
});
assert.notStrictEqual(result.exitCode, 0);
assert.strictEqual(JSON.parse(result.stderr).error.code, "WORKTREE_NOT_FOUND", result.stderr);
}
} finally {
await daemon.stop();
}
console.log("=== All worktree tests passed ===");
-4
View File
@@ -364,10 +364,6 @@ try {
ids.every((id) => id.startsWith("gpt-")),
"all codex model IDs should be from the gpt family",
);
assert(
ids.some((id) => id.includes("codex")),
"codex model list should include at least one codex-optimized model",
);
assert(
data.every((m) => m.model && m.id && m.description),
"every codex model should have model, id, and description fields",
@@ -848,6 +848,11 @@ async function assertSandboxState({ browser, page, expectedSandbox, stdout, stde
function assertLinuxDesktopIdentity(appPath) {
if (process.platform === "linux") {
assert.deepEqual(
fs.readFileSync(path.join(appPath, "resources", "icon.png")),
fs.readFileSync(path.join(__dirname, "..", "assets", "icon.png")),
"Packaged Linux windows must have the Paseo icon at their runtime lookup path",
);
const metadata = JSON.parse(
extractFile(path.join(appPath, "resources", "app.asar"), "package.json").toString(),
);
+2
View File
@@ -65,6 +65,8 @@ linux:
extraResources:
- from: bin/paseo
to: bin/paseo
- from: assets/icon.png
to: icon.png
target:
- AppImage
- deb
@@ -47,3 +47,33 @@ describe("renderTerminalSnapshotToAnsi", () => {
expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP");
});
});
describe("renderTerminalSnapshotToAnsi wide chars", () => {
it("skips wide-char continuation cells so the glyph keeps its two columns", () => {
// The daemon captures "통" as the glyph followed by an empty continuation
// cell. Emitting anything for that cell pushes the rest of the row right.
const state: TerminalState = {
rows: 1,
cols: 10,
scrollback: [],
grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]],
cursor: { row: 0, col: 5 },
};
expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!");
});
it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => {
// Old daemons already collapsed the continuation to " ". The serializer
// cannot tell that apart from a real space, so it must not guess.
const state: TerminalState = {
rows: 1,
cols: 10,
scrollback: [],
grid: [cells("통 합 !")],
cursor: { row: 0, col: 6 },
};
expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !");
});
});
@@ -87,6 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
// An empty char marks a wide-char continuation cell. The glyph in the
// previous cell already advances the cursor two columns, so emitting
// anything here would shift the rest of the row one column right.
if (cell.char === "") {
continue;
}
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/schedule-run-lifecycle.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -29,6 +29,7 @@ async function runSupervisorFixture(options: {
blockLogPath?: boolean;
/** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */
closeOutput?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
@@ -84,7 +85,9 @@ async function runSupervisorFixture(options: {
];
const child = spawn(command, args, {
cwd: repoRoot,
env: { ...process.env },
// A Node color-conflict warning can hit closed stderr before runSupervisor installs
// its pipe-error handlers. This fixture exercises the supervisor's own output.
env: { ...process.env, ...options.env, NO_COLOR: undefined, FORCE_COLOR: undefined },
stdio: ["ignore", "pipe", "pipe"],
});
@@ -205,10 +208,16 @@ describe("supervisor durable logging", () => {
expect(result.stderr).toContain('"worker-json-stderr"');
});
test("keeps supervising the worker when nobody reads its stdout or stderr", async () => {
const result = await runSupervisorFixture({
closeOutput: true,
workerSource: `
test.each([
{ colors: "inherited", env: {} },
{ colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } },
])(
"keeps supervising the worker when nobody reads its stdout or stderr with $colors colors",
async ({ env }) => {
const result = await runSupervisorFixture({
closeOutput: true,
env,
workerSource: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
@@ -220,14 +229,15 @@ describe("supervisor durable logging", () => {
}, 500);
setInterval(() => {}, 1000);
`,
});
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain("first stderr line\n");
expect(result.log).toContain("later stdout line\n");
expect(result.log).toContain('"reason":"closed_output_probe"');
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain("first stderr line\n");
expect(result.log).toContain("later stdout line\n");
expect(result.log).toContain('"reason":"closed_output_probe"');
},
);
test("preserves raw non-JSON stdout and stderr lines", async () => {
const result = await runSupervisorFixture({
@@ -39,6 +39,45 @@ function row(seq: number, item: AgentTimelineItem): AgentTimelineRow {
}
describe("curateAgentActivity", () => {
it("keeps reasoning before the assistant text that followed it", () => {
const timeline: AgentTimelineItem[] = [
{ type: "reasoning", text: "Check the commands first." },
{ type: "assistant_message", text: "I will list the commands." },
toolCallItem({
callId: "shell-order",
name: "Bash",
detail: {
type: "shell",
command: "ls packages/cli/src/commands/agent/",
output: "",
exitCode: 0,
},
}),
];
expect(curateAgentActivity(timeline)).toBe(
"[Thought] Check the commands first.\nI will list the commands.\n[Shell] ls packages/cli/src/commands/agent/",
);
});
it("preserves alternating text kinds, labels, and fragment concatenation", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "First message." },
{ type: "reasoning", text: "First " },
{ type: "reasoning", text: "thought." },
{ type: "assistant_message", text: "Second " },
{ type: "assistant_message", text: "message." },
{ type: "reasoning", text: "Second thought." },
];
expect(curateAgentActivity(timeline, { labelAssistantMessages: true })).toBe(
"[Assistant] First message.\n[Thought] First thought.\n[Assistant] Second message.\n[Thought] Second thought.",
);
expect(curateAgentActivity(timeline, { maxItems: 2 })).toBe(
"Second message.\n[Thought] Second thought.",
);
});
it("renders user/assistant/reasoning entries", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Hello" },

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