Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 1b192f697a test(usage): reproduce a retained agent account after login switching
Diagnostic-only branch: both cases intentionally fail. The registry retains an agent account while its session key stays the same, even after a forced refresh.
2026-11-03 08:45:44 +00:00
paseo-bot[bot]andJiayang Wu bd5671fd49 fix(usage): discover migrated database logins and relative paths
Match the source application’s migration and database-path rules so usage discovery does not keep reporting a stale file login after migration.

Co-authored-by: Jiayang Wu <651475459@qq.com>
2026-11-03 08:29:14 +00:00
Jiayang Wu d5f19aed37 fix(usage): read the OpenCode v2 login store in the Codex usage source
OpenCode v2 keeps its credentials in its own database and no longer writes
the v1 credential file. The Codex usage source only knew the v1 stores, so
on a v2 host it kept reporting the last file login it ever saw, with that
file's expiry date, instead of the ChatGPT login the host actually uses.

Read the active OpenAI credential from the OpenCode v2 database when it
holds one, and keep the v1 file as the source for installations without
that database. The database wins when both exist, because the file login
is the one that goes stale.

Report the v2 store with the same OpenCode harness and refresh command as
the v1 file store, so a rejected or expired login still names opencode.

Cover discovery, store precedence, expiry, token rotation and read-only
access with tests against real temporary SQLite databases.

Related to getpaseo/paseo#8645
2026-11-03 15:31:15 +08:00
Tahir Khan b807c261ca Show progress while plugin screens load (#8123)
Plugin screens can report unavailable while a successful catalog request
is still pending. Track the host catalog state so these screens show
progress until their contributions register.

Keep missing surfaces, failed requests, and offline hosts distinct from
loading. Retain existing installed surfaces during catalog refreshes.

Cover pending-to-ready rendering with a real browser regression and
catalog lifecycle tests.

Related to getpaseo/paseo#7088
2026-11-03 06:05:00 +00:00
Mohamed Boudra 85be2121b8 Link #123 issue and pull request references in chat (#8636)
* feat(app): link #123 issue and pull request references in Markdown

Agents cite issues and pull requests by number; reading one meant looking
up the URL by hand. Markdown in a workspace now links forge shorthand
against the workspace's origin remote: `#N`, GitLab and Gitea `!N`, and
cross-repository `owner/repo#N` on the same host.

Each forge's URL grammar declares which sigil maps to which path, so the
syntax stays per forge: GitHub has no `!`, and its `#` targets /issues/,
which GitHub redirects to the pull request. Recognition goes through the
Markdown engine's text link regex so code spans, fences and authored links
stay plain without a second Markdown parse while streaming. The reference
alternative sits before path recognition so `group/project#12` is not read
as a file path.

* fix(app): keep http:// for self-hosted forge web links

A self-hosted forge cloned over http:// serves its web UI over http on
the same origin. Forcing https made reference, blob and tree links fail
against an HTTP-only port. Cloud hosts and SSH remotes stay on https.
2026-11-03 13:00:24 +07:00
Mohamed Boudra f4eeb8a538 chore(release): reconcile main with v0.15.0 (#8635)
Bring in what v0.15.0 shipped: the 0.15.0 entry in CHANGELOG.md and
CHANGELOG.zh-CN.md exactly as tagged (replacing the 0.15.0-beta.1 series
entry), version 0.15.0 across package.json/package-lock.json, the F-Droid
changelogs 150001-150004.txt, and the matching Nix npm deps hash.
2026-11-03 12:44:50 +07:00
Mohamed Boudra e1b20384a1 fix(pi): restore complete chat history after compaction (#8523)
* fix(pi): restore complete active-branch chat history

Replay the saved transcript ancestry instead of the compacted model context so older messages retain their native rewind identities and Fork chat boundaries.

* refactor(pi): name runtime options and assert restored tool output

* fix(pi): accept native system messages with text blocks
2026-11-03 12:20:34 +07:00
Mohamed Boudra fc710be1af fix(app): keep linked badges inline in rendered Markdown on web (#8530)
The library's web renderer marked an image inline only when it was a
direct child of a paragraph with other content. A linked image sits
under a Link node, so badges such as a review bot's
`[![Retrigger](badge.svg)](url) Confidence Score` header rendered as
100%-wide, 320px-tall block images in the PR panel and file preview.

Images nested in links (or emphasis) now count as inline when their
paragraph has other content; a paragraph made only of images or
image-only links still renders them as blocks. Inline images now take
the line height with automatic width instead of a line-height square,
so wide badges and photos keep their aspect ratio instead of squashing.
2026-11-03 11:46:15 +07:00
paseo-bot[bot]andOkan YILDIRIM 66e1523f2f Keep Codex multi-file patch diffs in the timeline (#8613)
* fix(codex): retain multi-file patch diffs in timeline details

Combine each file's diff in canonical file-change items and legacy patch
text, keeping the legacy parsers and envelope unchanged. Cover both legacy
lifecycle events and the failed status.

* fix(codex): handle path-only and moved files in patch diffs

Multi-file assembly generated headers without patch text and discarded
move destinations when reducing object kind tags. Skip textless entries
and retain move_path for generated destination headers, keeping the
single-file mapping and existing truncation limit.

Add mapper regressions for both cases, demonstrated failing on 179c3f7f6
before the fix.

* fix(codex): keep later files in bounded patch previews

Share the existing diff detail budget across files so a large first diff cannot hide later changes in canonical or legacy patch events.

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

* fix(codex): preserve patch preview labels and move destinations

Keep complete file and hunk labels within the preview budget and count files omitted for space. Select the primary file from rendered content and retain move destinations in path-keyed and legacy events.

* fix(codex): bound generated file additions without argument overflow

* fix(codex): retain move-only files in multi-file previews

Represent moves without content changes as rename metadata and remove the provider move notice before classifying diff contents.

* fix(codex): keep move normalization inside multi-file previews

Recognize the provider move notice only while assembling multi-file diffs so the existing single-file path continues to retain its destination.

* fix(codex): retain empty additions in multi-file previews

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 04:06:44 +00:00
Mohamed Boudra 336e7d1b89 Add first-run setup that walks new users through Paseo (#8363)
* feat(app): add first-run setup modal

New users landed on an empty "Add a project" screen and never found
providers, Paseo tools or phone pairing, which all live in Settings. A
one-time setup modal now walks them through Appearance, Providers,
Projects, Orchestration and Phone. Every step renders the same component
Settings uses, so changing something in setup changes it in Settings too.

The modal opens only when the setup host is connected, its workspaces have
loaded and it has no projects. Existing users already have projects, so
they never see it after upgrading and no migration is needed. Finishing or
skipping writes `@paseo:onboarding` on this client. It uses its own key
because it records feature state, and e2e specs replace app settings
wholesale. Settings > General > "Show setup again" reopens the modal on
step 1. Native phones skip the modal for now.

Shared pieces moved out so setup and Settings render the same thing:
- ThemePicker is a tile grid and replaces the Settings theme dropdown.
- PaseoToolsCard is extracted from the host page.
- The skeleton pulse moves to components/ui. Provider rows show it while
  the first snapshot loads instead of "Loading…".
- The pair-device direct-connection hint links to the Tailscale docs.

The e2e fixture marks setup completed by default so existing specs keep
their starting state. The desktop smoke and lifecycle scripts, which start
from a fresh daemon, mark it before they continue.

* feat(onboarding): suggest projects from agent history

The setup's Projects step lists the repos the user already ran agents in,
with busy recent ones preselected, so a new user adds their projects in one
click instead of browsing for folders. With rows checked, the footer's main
button adds them and only moves on once every one was added; a checked row
always means "this will be added".

The daemon resolves each session folder to its project root (main checkout
for worktrees, repo root for subfolders) because only it can read the git
state; the app asks once per provider so a slow or broken provider never
holds up the others and can be retried on its own. Hosts without the
projectSuggestions feature show only the Add project button.

Onboarding e2e hosts read an empty Claude history with other providers
off, so suggestions never depend on the machine running the tests.

* fix(onboarding): keep late project adds on their step and show Retry early

- A finished add only advances setup while the Projects step is still open.
- A failed provider shows its Retry as soon as any provider answers.
- The desktop lifecycle check no longer reloads the window: setup never opens
  there because no host is connected.

* fix(onboarding): bind late project adds to their step visit and freeze rows while adding

- Each step visit has an id; a finished add only advances the visit that started it,
  so leaving and returning to Projects, or reopening setup, ignores it.
- Suggestion rows are disabled while their batch is being added.

* test(app): drive theme selection through the tile picker

Settings → Appearance no longer has a theme dropdown. The Escape test opens the
syntax theme dropdown instead, and the Android system-bars check taps theme tiles.

* fix(server): return project suggestion roots in the form project.add stores

Git reports Windows repo roots with forward slashes, so an added suggestion kept
showing because its path never matched the new project's root.

* feat(app): two control sizes, orchestration switches and mobile connect choices in setup

Controls had four sizes picked per call site, so a button in a dialog, a
settings row and a setup step came out at different heights. Controls now
have two: md (default; 32px with a pointer, 44px in the compact layout so
phone controls are full touch targets) and sm (28px, for tight chrome and
nested settings rows). The breakpoint picks md's height, not the caller.

Setup:
- Orchestration is three switches (Paseo tools, agent skills, CLI), all on,
  applied on Continue. Turning one off undoes it; the desktop CLI uninstall
  removes only the file this app installed.
- The Mobile step has store buttons and a "Choose how to connect" section:
  Relay opens the existing pairing modal, Tailscale opens the docs.

Settings section docs links are one pattern, SettingsSection's `link` prop,
and modals sit on surface0 like a screen.

The vitest unistyles stub now resolves breakpoint values by window width,
as Unistyles does at runtime; without it md controls had no height in
browser tests.

* fix(app): finish setup on its last step and confirm undoing orchestration

In the compact layout Orchestration is the last step, and Finish closed
setup without applying its switches. The last step's work now runs before
Finish, the same as Continue.

Turning off skills now asks first, as Settings does. Removing the CLI keeps
a paseo command this app didn't install and says so. A failed Paseo tools
update shows a toast. Adding suggested projects no longer advances when a
row was checked while the add ran.

Chat sender attribution and the message preview toggle go back to the sm
button: md padding is a breakpoint value, and on web its media-query rule
beat the zero right padding that aligns the sender with its bubble.

* fix(app): ignore a late Finish once the user left the last setup step

Finish called complete(), which ignored the step visit, so pressing Back
while Orchestration applied its choices still closed setup and recorded it
as done. next(visit) now finishes setup from the last step, so a late
Finish is dropped the same way a late Continue already was.

* fix(app): hold the skills switch while setup applies its choices

The Paseo tools and CLI switches were disabled while Continue or Finish
applied them, but the skills switch only locked while a skills operation
ran. With skills needing no change, it stayed editable during a pending
tools update and the new value was silently dropped.

* fix(app): read setup's skills choice as off when no skill is picked

Saving an empty selection in the skills picker left setup's skills switch
on. Continue then asked the host to install nothing, the host reported
skills as not installed, and the step stayed put with no error. Setup now
reads skills as off with nothing picked, as Settings does.
2026-11-03 10:28:26 +07:00
Mohamed Boudra 6f6712be62 Make interrupted-agent recovery opt-in (#8619)
* fix(agents): require opt-in for restart recovery

Keep automatic interrupted-turn resumption off during further dogfooding. Preserve explicit opt-in and custom resume prompts.

* test(settings): verify restart recovery opt-in persists

* docs(agents): describe restart recovery opt-in
2026-11-03 10:20:26 +07:00
paseo-bot[bot]and见安 a296bf3502 Run cron schedules when either restricted day field matches (#8611)
* fix(schedules): match either restricted cron day field

* refactor(schedules): name the combined day match conditions

Keep wildcard days conjunctive so weekday-only and date-only schedules
retain their meaning, while restricted day fields follow cron's union
rule. Preserve lexical wildcard identity rather than inferring it from
allowed values, since an explicit full range is still restricted.

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

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
2026-11-03 02:52:16 +00:00
paseo-bot[bot]andhadan8977 3857ed3365 fix(cli): return JSON from agent logs (#8589)
* fix(cli): print JSON from paseo logs --json

paseo logs ignored the global --json flag and always printed the text
transcript. Print { agentId, items } for one-shot logs, and one timeline
item per line in --follow mode, with the follow notices on stderr so
stdout stays parseable.

* fix(cli): honor the JSON format alias in agent logs

Verify JSON output through the real CLI and isolated daemon, including the documented --format json spelling.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

* test(cli): verify JSON follow history and live output channels

* fix(cli): stop JSON log followers when their reader closes at startup

Observe shutdown before saved history is written and retain an early stop through subscription setup. Dispose listeners and the subscription on every follow exit.

* fix(cli): interrupt pending log follow startup

Race history and subscription setup against shutdown so Ctrl+C does not wait for a slow daemon response.

---------

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 01:52:53 +00:00
paseo-bot[bot]and见安 736f7bf042 Reject cron steps that would silently run hourly (#8607)
* fix(schedules): reject ignored numeric cron steps

* fix(schedules): preserve saved numeric steps during recovery

Reject unsupported numeric steps at create/update while keeping saved
schedules executable with their previous singleton cadence.

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

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
2026-11-03 00:56:28 +00:00
paseo-bot[bot] 07a2e4accd test(server): prepare registry fixtures before the install deadline (#8597)
Keep real Git fixture construction outside the registry installation assertion budget. Preserve the reviewed-revision, source identity and request-count checks with the existing install deadline.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 00:26:23 +00:00
paseo-bot[bot]andOkan YILDIRIM 1f64e68987 fix(codex): show the file and diff before patch approval (#8585)
* fix(codex): include patch details in file change approvals

* fix(codex): name the file in patch approvals

The approval detail renderer shows the diff without its filePath. Include the file in the approval title so the pending decision identifies the change being reviewed.

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

* fix(codex): await patch notifications before approval

Approval requests bypass the session's notification conversion queue. Drain earlier notifications before reading the patch preview so an approval in the same stdout batch receives its matching file and diff.

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

* test(codex): express patch approval through provider actions

Keep protocol framing in the fake app-server and precompute the approval title. Streamed completion already requires an item ID; expose that narrowing so completion cleanup stays inline without repeating its guard.

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 23:01:24 +00:00
paseo-bot[bot]andhadan8977 e06adf7a3e Show new files in collapsed folders after Refresh (#8582)
* fix(app): show current contents of collapsed folders after Files refresh

Refresh only re-listed expanded folders, and expanding a folder skips the
request when a listing is cached, so a folder collapsed during Refresh
kept showing its old contents. Drop the cached listings Refresh does not
re-list. The refresh helper moves to file-explorer/tree.ts so it can be
tested.

* test(app): cover refreshing collapsed folders through the browser

Exercise the reported file creation, refresh and reopen sequence through a real daemon, then check refresh while expanded.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

* fix(app): ignore directory replies invalidated by Refresh

A directory request started before Refresh could finish afterwards and restore a stale collapsed listing. Track the refresh generation in explorer state and accept completion only from the current generation. Keep directory loading and invalidation in the file-explorer model.

* fix(app): preserve expansion when Refresh cancels restoration

Keep an invalidated directory operation distinct from a failed listing. Propagate cancellation through saved-folder restoration so it preserves the current expansion state instead of committing an incomplete restored path set.

---------

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 21:59:28 +00:00
luke d2cc18b3a1 fix(agents): clear stale error attention when a new turn starts (#8083) 2026-11-02 21:02:09 +00:00
paseo-bot[bot] 5062d5ee90 fix(server): report completion after waking remote closed agents (#8575)
A queued prompt can leave the previous closed snapshot visible until dispatch. Do not treat that snapshot as terminal when the finish wait times out; retain permission resolution while awaiting the new turn's outcome.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 19:05:43 +00:00
paseo-bot[bot] 71dc9f44c9 fix(server): finish Git refreshes before daemon shutdown (#8579)
Retain admitted refresh promises until they settle, including after the last workspace subscriber leaves. Disposal closes admission and waits for those commands before callers can remove their working directories.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 18:29:15 +00:00
7495c20f78 fix(app): keep HTML section links inside the preview (#8559)
* fix(app): keep HTML preview fragment links inside the preview

A file previewed through `srcDoc` resolves `<a href="#section">` against the
app's URL, so the link loaded Paseo inside the iframe and the preview became
the Welcome screen. Handle same-document fragment clicks from a small script
in the preview document, and let the page's own handlers go first.

Closes #7465

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

* fix(app): preserve native HTML fragment navigation in previews

Give each HTML preview its own document URL so section links resolve within the file. Browser default navigation preserves cancellation by page handlers without an injected click interceptor.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 15:54:49 +00:00
Mohamed Boudra b92a34ab3d Keep reconnecting and updating status visible in native chats (#8568)
* fix(app): keep chat status toasts visible below floating headers

Compact native chats extend beneath the floating header. Panel toasts need measured header clearance and a layer above its fade; placing them below the fade leaves an excessive gap. Keep dock overlays outside keyboard translation so opening the keyboard cannot move them offscreen.

* test(app): verify rendered chat status spacing on native devices

Measure the real header and toast bounds with the software keyboard closed and open. The former arithmetic helper test passed even when the viewport stopped consuming header clearance; the native regression fails against that broken production wiring.

* test(app): exercise toast placement through host disconnection

Observe the public reconnecting state with host connectivity interrupted externally. Keep native bounds and software keyboard assertions while removing runtime evaluation and private timeline-owner mutations.
2026-11-02 22:49:53 +07:00
paseo-bot[bot]andRyan Winograd 8018231a00 fix(pi): release archived chat runtimes after history replay (#8554)
* fix(pi): close history-only runtimes after replay

Archived timeline reads resume a Pi RPC process to reconstruct the saved
conversation. The history-only session remained open after replay, so
browsing or synchronizing archived agents left one idle Pi process per
agent.

Mark history-purpose resumes for closure and close the runtime in
streamHistory's finally path. Interactive sessions remain resident, and
idempotent close keeps later manager cleanup safe.

* fix(pi): preserve history cleanup across current resume behavior

Keep the original session identity and missing-folder cleanup when releasing
history-only runtimes. Use an existing directory in the regression so the
newer resume fallback does not try to copy a nonexistent session fixture.
Cover interrupted and failed replay, interactive residency, and automatic
cleanup of a missing-folder history copy.

Co-authored-by: Ryan Winograd <ryan@thewinograds.com>

* test(pi): separate stopped and failed history replay cases

---------

Co-authored-by: Ryan Winograd <ryan@thewinograds.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 14:53:40 +00:00
555818f5f7 fix(acp): clear abandoned approvals without leaving providers blocked (#8548)
* fix(acp): clear permission requests a finished turn abandoned

An ACP agent can give up on a permission request itself and end its turn
normally. Paseo kept that request pending, so the approval card stayed up
with nothing listening, and the next prompt reported the agent as waiting.

Cancel a turn's open requests whenever that turn ends, cancel a request
when its tool reaches a terminal status, and publish the resolved event in
both cases. Requests raised outside a turn stay answerable.

Closes #7664

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

* fix(acp): keep permission cleanup consistent after failed turns

Failed and canceled turns clear every permission in the agent manager. Answer that same set in the ACP session so a request raised outside the foreground turn cannot leave the provider waiting after its approval disappears.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 13:22:46 +00:00
paseo-bot[bot] 27caf913f6 test(server): trim subdirectory setup fixture overhead (#8558)
Exercise setup against an existing checkout instead of constructing Git history and a worktree inside its deadline. Preserve the real shell command and both output-location assertions.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 12:53:00 +00:00
281 changed files with 10542 additions and 2291 deletions
+5 -4
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.15.0-beta.1 - 2026-11-01
## 0.15.0 - 2026-11-03
Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
@@ -13,7 +13,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- Added an opt-in setting to resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
@@ -64,6 +64,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- Fixed chat reconnecting and updating status being hidden behind the header on iOS and Android phones ([#8568](https://github.com/getpaseo/paseo/pull/8568))
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
@@ -74,7 +75,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
### Notes
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents
- Agent recovery is off by default; turn on **Resume agents after restart** in Host settings → Agents to send `Resume working.` or your own prompt
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
## 0.14.0 - 2026-10-29
+5 -4
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.15.0-beta.1 - 2026-11-01
## 0.15.0 - 2026-11-03
daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
@@ -13,7 +13,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- 新增可选设置,在主机重启后于原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
@@ -64,6 +64,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- 修复了 iOS 和 Android 手机聊天中重新连接和更新状态被顶栏遮挡的问题 ([#8568](https://github.com/getpaseo/paseo/pull/8568))
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
@@ -74,7 +75,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
### 说明
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
- Agent 恢复默认关闭;在主机设置 → Agent 中开启 **重启后恢复智能体**,即可发送 `Resume working.` 或自定义提示词
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
## 0.14.0 - 2026-10-29
+4 -3
View File
@@ -14,9 +14,10 @@ Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idl
## Daemon restart recovery
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
Restart recovery is off by default. Enable `agents.resumeOnStartup` in host config or
Host settings → Agents to resume interrupted agents after graceful shutdown or a crash. The daemon
reopens the saved provider conversation and settings, then sends `Resume working.`. Customize
the prompt with `agents.resumePrompt` or the same settings page.
Disabling recovery consumes pending intent; enabling it later does not revive old work.
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
+9 -9
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -36,7 +36,7 @@ Weight has three tiers, applied by role:
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
@@ -68,9 +68,9 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px with `fontSize.sm` labels and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Both sit on `surface0`, the same base as a screen, so settings cards and inputs inside a modal read the way they do on the Settings screen. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
@@ -126,7 +126,7 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
The whitespace is the design.
@@ -197,11 +197,11 @@ Terminology:
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `sm` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
@@ -251,7 +251,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
## 14. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `fontWeight.medium` on row titles, body text, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
+8 -1
View File
@@ -125,7 +125,8 @@ rendering stack:
`acme.ts` exports a `ClientForgeLogicModule`:
- `id`
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment)
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment
and Markdown shorthand references)
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
`acme.view.tsx` exports a `ClientForgeViewModule`:
@@ -212,6 +213,12 @@ are stale, run `npm run build:server`.
neutral extractor matches the pasted host and full repository path against
the current remote before it invokes cwd-routed forge search. Do not add a
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
- Shorthand references in rendered Markdown (`#12`, GitLab and Gitea `!12`,
cross-repository `owner/repo#12`) come from the `sigil` on each
`referencePaths` entry. Leave `sigil` off a kind the forge does not link in
prose: GitHub has no `!`, and its `#` targets `/issues/`, which GitHub
redirects to the pull request. `packages/app/src/git/forge-shorthand` builds
the recognition pattern from the workspace descriptor's forge and remote.
- GitLab pipeline status constants belong to the GitLab adapter/client module,
not protocol.
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
+2 -2
View File
@@ -10,9 +10,9 @@ Every block is separated by one gap, `density.blockGap`: 12 on compact layouts,
## Links and images
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. Inside a workspace, forge references such as `#12` and `owner/repo#12` are recognized in prose (not code) and open the workspace forge; the reference pattern comes before path recognition so `group/project#12` is not read as a path. See [forge providers](forge-providers.md#gotchas).
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline at line height and keep their aspect ratio. A linked image counts as an image, so review-bot badges such as `[![Retrigger](badge.svg)](url) Confidence` stay inline on web; the library's web renderer only checked direct paragraph children, so it rendered them as full-width block images (local patch in `patches/react-native-enriched-markdown+*.patch`).
## Engine constraints
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
sha256-iAi82L6Lm75hdg/9tJYyYcHMhCJLtXGSpcNqgv9QQaw=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36762,7 +36762,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -38930,12 +38930,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/server": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/server": "0.15.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39182,10 +39182,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39197,7 +39197,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40034,7 +40034,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40080,7 +40080,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40109,15 +40109,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40127,8 +40127,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40150,7 +40150,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40177,7 +40177,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40398,16 +40398,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/highlight": "0.15.0-beta.1",
"@getpaseo/plugin": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/highlight": "0.15.0",
"@getpaseo/plugin": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41354,7 +41354,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -42964,7 +42964,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -9,9 +9,11 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
await themeTrigger.click();
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked();
await expect(
page.getByRole("radiogroup", { name: "Theme" }).getByRole("radio").first(),
).toHaveAccessibleName("System");
await expect(page.getByRole("radio", { name: "Pure black", exact: true })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("appearance-theme-picker.png"),
fullPage: true,
@@ -63,6 +63,24 @@ function htmlPreview(page: Page) {
};
}
async function followHtmlPreviewLink(page: Page, name: string): Promise<void> {
await htmlPreview(page).document.getByRole("link", { name, exact: true }).click();
}
async function expectPageHandlesFragment(
page: Page,
handler: "Document" | "Window",
): Promise<void> {
const preview = htmlPreview(page);
await followHtmlPreviewLink(page, `${handler} handled jump`);
await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`);
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).not.toBeInViewport();
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
}
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view,
@@ -826,6 +844,192 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(preview.host).toBeVisible();
});
test("keeps HTML fragment links in the preview and scrolls to the target", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "html-fragments-" });
await writeFile(
path.join(workspace.repoPath, "fragments.html"),
`<!doctype html><html><body>
<a href="#section">Jump to section</a>
<div style="height: 1200px"></div>
<h2 id="section">Target section</h2>
</body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "fragments.html");
const preview = htmlPreview(page);
await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") });
await preview.document.getByRole("link", { name: "Jump to section" }).click();
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).toBeInViewport();
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section");
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan(
0,
);
await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") });
});
test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "html-fragment-handler-" });
await writeFile(
path.join(workspace.repoPath, "handled.html"),
`<!doctype html><html><body>
<a href="#section" onclick="event.preventDefault(); document.querySelector('output').textContent = 'Page handled click'">Handled jump</a>
<output>Waiting for click</output>
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
</body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "handled.html");
const preview = htmlPreview(page);
await preview.document.getByRole("link", { name: "Handled jump" }).click();
await expect(preview.document.locator("output")).toHaveText("Page handled click");
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).not.toBeInViewport();
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
});
test("respects delegated HTML fragment handlers and normal URL parsing", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" });
await writeFile(
path.join(workspace.repoPath, "defaults.html"),
`<!doctype html><html><body>
<nav style="position: fixed; top: 0; background: white">
<a href="#section" id="document-handler">Document handled jump</a>
<a href="#section" id="window-handler">Window handled jump</a>
<a href=" \t#section\n">Whitespace jump</a>
<a href="#100%">Percent jump</a>
<output>Waiting for click</output>
</nav>
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
<div style="height: 1200px"></div><h2 id="100%">Percent target</h2>
<div style="height: 1200px"></div>
<script>
document.addEventListener('click', function(event) {
if (event.target.id === 'document-handler') {
event.preventDefault(); document.querySelector('output').textContent = 'Document handled click';
}
});
window.addEventListener('click', function(event) {
if (event.target.id === 'window-handler') {
event.preventDefault(); document.querySelector('output').textContent = 'Window handled click';
}
});
</script></body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "defaults.html");
const preview = htmlPreview(page);
await expectPageHandlesFragment(page, "Document");
await expectPageHandlesFragment(page, "Window");
await followHtmlPreviewLink(page, "Whitespace jump");
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).toBeInViewport();
await followHtmlPreviewLink(page, "Percent jump");
await expect(
preview.document.getByRole("heading", { name: "Percent target" }),
).toBeInViewport();
});
test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "html-fragment-targets-" });
await writeFile(
path.join(workspace.repoPath, "targets.html"),
`<!doctype html><html><head><style>:target { color: rgb(255, 0, 0); }</style></head><body>
<nav style="position: fixed; top: 0; background: white">
<a href="#section"><span>Nested jump</span></a>
<a href="#legacy">Named jump</a>
<a href="#encoded%20section">Encoded jump</a>
<a href="#missing">Missing jump</a>
<a href="#">Empty jump</a>
<output>Waiting for hashchange</output><output id="modified">Waiting for modified click</output>
</nav>
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
<div style="height: 1200px"></div><a name="legacy">Named target</a>
<div style="height: 1200px"></div><h2 id="encoded section">Encoded target</h2>
<div style="height: 1200px"></div>
<script>
window.addEventListener('hashchange', function () { document.querySelector('output').textContent = location.hash || '(empty)'; });
function modifiedClick(event) {
if (event.button === 1 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
document.getElementById('modified').textContent = String(event.defaultPrevented);
event.preventDefault();
}
}
window.addEventListener('click', modifiedClick);
window.addEventListener('auxclick', modifiedClick);
window.addEventListener('contextmenu', modifiedClick);
</script></body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "targets.html");
const preview = htmlPreview(page);
const body = preview.document.locator("body");
const nested = preview.document.getByText("Nested jump", { exact: true });
await nested.click();
await expect(preview.document.locator("output").first()).toHaveText("#section");
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).toBeInViewport();
await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)");
await body.evaluate(() => window.scrollTo(0, 0));
await nested.click();
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).toBeInViewport();
await preview.document.getByRole("link", { name: "Named jump" }).click();
await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport();
await expect(preview.document.locator("output").first()).toHaveText("#legacy");
await preview.document.getByRole("link", { name: "Encoded jump" }).click();
await expect(
preview.document.getByRole("heading", { name: "Encoded target" }),
).toBeInViewport();
await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section");
const scrollY = await body.evaluate(() => window.scrollY);
await preview.document.getByRole("link", { name: "Missing jump" }).click();
expect(await body.evaluate(() => location.hash)).toBe("#missing");
expect(await body.evaluate(() => window.scrollY)).toBe(scrollY);
await preview.document.getByRole("link", { name: "Empty jump" }).click();
await expect(preview.document.locator("output").first()).toHaveText("(empty)");
expect(await body.evaluate(() => window.scrollY)).toBe(0);
for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) {
await nested.click({ modifiers: [modifier] });
await expect(preview.document.locator("#modified")).toHaveText("false");
}
await nested.click({ button: "middle" });
await expect(preview.document.locator("#modified")).toHaveText("false");
expect(await body.evaluate(() => location.hash)).toBe("");
expect(await body.evaluate(() => window.scrollY)).toBe(0);
});
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
@@ -1,4 +1,6 @@
import { test } from "../support/fixtures";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import {
collapseFolder,
expandFolder,
@@ -8,6 +10,7 @@ import {
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import { delayDirectoryListingResponse } from "../support/helpers/directory-listing-gate";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -49,3 +52,66 @@ test.describe("File explorer collapse", () => {
await expectExplorerEntryVisible(page, "guide.md");
});
});
test("Files Refresh reveals new files when a cached collapsed folder is reopened", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
await collapseFolder(page, "docs");
await expectExplorerEntryHidden(page, "guide.md");
await createFileOutsidePaseo("docs/NEW.md");
await refreshFiles(page);
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
await expectExplorerEntryVisible(page, "NEW.md");
await createFileOutsidePaseo("docs/NEXT.md");
await refreshFiles(page);
await expectExplorerEntryVisible(page, "NEXT.md");
});
async function refreshFiles(page: import("@playwright/test").Page): Promise<void> {
const refresh = page.getByRole("button", { name: "Refresh files", exact: true });
await refresh.click();
await expect(refresh).toBeEnabled();
}
async function createFileOutsidePaseo(filePath: string): Promise<void> {
await writeFile(path.join(workspace.repoPath, filePath), "# New output\n");
}
test("Files Refresh ignores an old reply for a folder collapsed while loading", async ({
page,
}) => {
const reply = await delayDirectoryListingResponse(page, "docs");
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "docs");
await reply.waitUntilHeld();
await collapseFolder(page, "docs");
await createFileOutsidePaseo("docs/DELAYED.md");
await refreshFiles(page);
await reply.release();
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "DELAYED.md");
});
test("Files Refresh preserves expanded folders while their saved view is restoring", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
const reply = await delayDirectoryListingResponse(page, "docs");
await page.reload();
await openFileExplorer(page);
await reply.waitUntilHeld();
await refreshFiles(page);
await expectExplorerEntryVisible(page, "guide.md");
await reply.release();
await expectExplorerEntryVisible(page, "guide.md");
});
+311
View File
@@ -0,0 +1,311 @@
import { execFileSync } from "node:child_process";
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { expect, metroTest as test } from "../support/fixtures";
import {
addProjectFromSetup,
continueSetup,
expectSetupStep,
finishSetup,
goToSetupStep,
hostGivesAgentsPaseoTools,
openAppAsNewUser,
openRelayPairingFromSetup,
closeRelayPairing,
openTailscaleDocsFromSetup,
paseoToolsSwitch,
pickTheme,
projectSuggestion,
projectSuggestionRow,
setupModal,
setupPrimaryButton,
showSetupAgainFromSettings,
skipSetup,
startSetupHost,
} from "../support/helpers/onboarding";
import {
chooseCustomSkills,
openSkillSelection,
saveSkillSelection,
} from "../support/helpers/agent-skills";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsHostSection } from "../support/helpers/settings";
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
const MIDNIGHT_MUTED_FOREGROUND = "rgb(154, 157, 176)";
test("a new user is walked through setup once", async ({ page }, testInfo) => {
const host = await startSetupHost("fresh");
const repo = await createTempGitRepo("onboarding-folder-");
try {
await openAppAsNewUser(page, host);
await test.step("setup opens on Appearance and a theme applies at once", async () => {
await expectSetupStep(page, "Welcome to Paseo");
await pickTheme(page, "Midnight");
await expect(
setupModal(page).getByText("Let's start by picking a look", { exact: true }),
).toHaveCSS("color", MIDNIGHT_MUTED_FOREGROUND);
await page.screenshot({ path: testInfo.outputPath("1-appearance.png") });
});
await test.step("Providers shows the same provider list as Settings", async () => {
await continueSetup(page);
await expectSetupStep(page, "Your agents");
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
});
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
await continueSetup(page);
await expectSetupStep(page, "Add your projects");
await expect(
setupModal(page).getByText("No recent projects found", { exact: true }),
).toBeVisible({ timeout: 60_000 });
await addProjectFromSetup(page, repo.path);
await expectSetupStep(page, "Add your projects");
await expect(setupModal(page).getByText(repo.path, { exact: true })).toBeVisible();
await expect(setupModal(page).getByText("Added", { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("3-projects.png") });
});
await test.step("Orchestration starts with its switches on", async () => {
await continueSetup(page);
await expectSetupStep(page, "Let agents use Paseo");
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
await expect(
setupModal(page).getByRole("switch", { name: "Orchestration skills", exact: true }),
).toBeChecked();
await page.screenshot({ path: testInfo.outputPath("4-orchestration.png") });
});
await test.step("Skip setup closes it for good", async () => {
await skipSetup(page);
await page.reload();
await expect(
page.locator('[data-testid^="sidebar-project-row-"]').filter({
hasText: path.basename(repo.path),
}),
).toBeVisible({ timeout: 60_000 });
await expect(setupModal(page)).toHaveCount(0);
});
} finally {
await host.close();
await repo.cleanup();
}
});
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
const repo = await createTempGitRepo("onboarding-history-");
const repoName = path.basename(repo.path);
const subdirectory = path.join(repo.path, "packages", "web");
await mkdir(subdirectory, { recursive: true });
const worktree = `${repo.path}-fix`;
execFileSync("git", ["worktree", "add", "-b", "fix", worktree], { cwd: repo.path });
const scratch = await createTempDirectory("onboarding-scratch-");
const scratchName = path.basename(scratch.path);
const host = await startSetupHost("history", {
claudeSessionsIn: [repo.path, subdirectory, worktree, scratch.path, scratch.path, scratch.path],
withBrokenProvider: true,
});
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
await goToSetupStep(page, "Projects");
await expectSetupStep(page, "Add your projects");
await test.step("the repo, its subfolder and its worktree become one checked row", async () => {
await expect(projectSuggestion(page, repoName)).toBeChecked({ timeout: 60_000 });
await expect(projectSuggestion(page, scratchName)).toBeChecked();
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
});
await test.step("a provider that cannot read its history adds nothing and shows no error", async () => {
await expect(setupModal(page).getByTestId("project-suggestions-skeleton")).toHaveCount(0, {
timeout: 60_000,
});
await expect(setupModal(page).getByText(/Broken ACP/)).toHaveCount(0);
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
});
await test.step("the footer adds what is checked; the Add project flow is separate", async () => {
await expect(setupPrimaryButton(page, "Add 2 projects")).toBeVisible();
await expect(setupModal(page).getByRole("button", { name: "Continue" })).toHaveCount(0);
await expect(
setupModal(page).getByRole("button", { name: "Add project", exact: true }),
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("8-project-suggestions.png") });
await projectSuggestion(page, repoName).click();
await projectSuggestion(page, scratchName).click();
await expect(setupPrimaryButton(page, "Continue")).toBeVisible();
await projectSuggestion(page, repoName).click();
await projectSuggestion(page, scratchName).click();
});
await test.step("a folder that cannot be added keeps the user on the step", async () => {
await scratch.cleanup();
await setupPrimaryButton(page, "Add 2 projects").click();
await expect(setupPrimaryButton(page, "Add 1 project")).toBeVisible({ timeout: 30_000 });
await expectSetupStep(page, "Add your projects");
await expect(projectSuggestion(page, scratchName)).toBeChecked();
await expect(projectSuggestionRow(page, scratchName)).toContainText("Directory not found");
await expect(projectSuggestion(page, repoName)).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("9-project-suggestion-failed.png") });
});
await test.step("with nothing checked, Continue moves on without adding", async () => {
await projectSuggestion(page, scratchName).click();
await setupPrimaryButton(page, "Continue").click();
await expectSetupStep(page, "Let agents use Paseo");
await expect(
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: repoName }),
).toBeVisible({ timeout: 60_000 });
await expect(
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: scratchName }),
).toHaveCount(0);
});
} finally {
await host.close();
await rm(worktree, { recursive: true, force: true });
await repo.cleanup();
await scratch.cleanup();
}
});
test("an existing user only sees setup when they ask for it", async ({ page }) => {
const host = await startSetupHost("existing");
const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: host.port });
try {
await openAppAsNewUser(page, host);
await expect(
page.getByTestId(`sidebar-workspace-row-${host.serverId}:${workspace.workspaceId}`),
).toBeVisible({ timeout: 60_000 });
await expect(setupModal(page)).toHaveCount(0);
await showSetupAgainFromSettings(page);
await expectSetupStep(page, "Welcome to Paseo");
await expect(setupModal(page).getByRole("tab", { name: "Appearance" })).toHaveAttribute(
"aria-selected",
"true",
);
} finally {
await workspace.cleanup();
await host.close();
}
});
test("setup choices carry over to Settings and the phone step pairs over relay", async ({
page,
}, testInfo) => {
const host = await startSetupHost("phone", { mutableRelay: { enabled: false } });
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
await test.step("Paseo tools start on in setup and Continue turns them on", async () => {
await goToSetupStep(page, "Orchestration");
await expectSetupStep(page, "Let agents use Paseo");
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
await continueSetup(page);
});
await test.step("Phone offers Tailscale docs and pairs over relay in its own modal", async () => {
await expectSetupStep(page, "Take your agents with you");
await page.screenshot({ path: testInfo.outputPath("5-phone.png") });
expect(await openTailscaleDocsFromSetup(page)).toBe(
"https://paseo.sh/docs/connectivity#tailscale",
);
const pairing = await openRelayPairingFromSetup(page);
await expect(pairing.getByText("Enable relay?", { exact: true })).toBeVisible();
await enableRelayAndExpectOffer(page);
await page.screenshot({ path: testInfo.outputPath("6-phone-qr.png") });
await closeRelayPairing(pairing);
});
await test.step("Finish closes setup and Settings shows the same Paseo tools value", async () => {
await finishSetup(page);
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await openSettingsHostSection(page, host.serverId, "agents");
await expect(paseoToolsSwitch(page)).toBeChecked();
await page.screenshot({ path: testInfo.outputPath("7-settings-agents.png") });
});
} finally {
await host.close();
}
});
test("with no skills picked, skills read as off and Continue moves on", async ({ page }) => {
const host = await startSetupHost("no-skills");
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
await goToSetupStep(page, "Orchestration");
await expectSetupStep(page, "Let agents use Paseo");
await openSkillSelection(page);
await chooseCustomSkills(page, []);
await saveSkillSelection(page);
const skillsSwitch = setupModal(page).getByRole("switch", {
name: "Orchestration skills",
exact: true,
});
await expect(skillsSwitch).not.toBeChecked();
await continueSetup(page);
await expectSetupStep(page, "Take your agents with you");
} finally {
await host.close();
}
});
test("in the compact layout, Finish on Orchestration applies its switches", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const host = await startSetupHost("compact");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
// The compact sheet is the only surface on screen, and Orchestration is its last step.
const step = (title: string) =>
expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible({
timeout: 60_000,
});
const primary = (label: string) => page.getByRole("button", { name: label, exact: true });
try {
await openAppAsNewUser(page, host);
await step("Welcome to Paseo");
await primary("Continue").click();
await step("Your agents");
await primary("Continue").click();
await step("Add your projects");
await primary("Continue").click();
// The setup host starts with Paseo tools off; the switch starts on.
expect(await hostGivesAgentsPaseoTools(host)).toBe(false);
await step("Let agents use Paseo");
await expect(paseoToolsSwitch(page)).toBeChecked();
// Skills are not installed, so leaving them off has nothing to apply.
const skillsSwitch = page.getByRole("switch", { name: "Orchestration skills", exact: true });
await skillsSwitch.click();
await expect(skillsSwitch).not.toBeChecked();
gate.holdNextClientRequest("set_daemon_config_request");
await primary("Finish").click();
// While the choices apply, none of them can change.
await gate.waitForHeldClientRequest();
await expect(paseoToolsSwitch(page)).toBeDisabled();
await expect(skillsSwitch).toBeDisabled();
gate.releaseHeldClientRequest();
await expect.poll(() => hostGivesAgentsPaseoTools(host), { timeout: 30_000 }).toBe(true);
await expect(page.getByRole("heading", { name: "Let agents use Paseo" })).toHaveCount(0);
} finally {
await host.close();
}
});
@@ -27,6 +27,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -133,6 +134,30 @@ function sidebarFooter(page: Page): Locator {
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
}
function isPluginCatalogResponse(message: string | Buffer): boolean {
if (typeof message !== "string") return false;
const frame = JSON.parse(message);
return frame.type === "session" && frame.message?.type === "plugin.catalog.get.response";
}
async function holdNextPluginCatalogResponse(page: Page) {
const response = Promise.withResolvers<() => void>();
let holdCatalog = true;
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
const server = socket.connectToServer();
socket.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (holdCatalog && isPluginCatalogResponse(message)) {
holdCatalog = false;
response.resolve(() => socket.send(message));
return;
}
socket.send(message);
});
});
return { response: response.promise };
}
test.describe("Plugin sidebar items", () => {
let workspaceId: string;
let cleanup: () => Promise<void>;
@@ -151,6 +176,38 @@ test.describe("Plugin sidebar items", () => {
await cleanup();
});
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
const body = page.getByText("Deploys screen body", { exact: true });
await expect(body).toBeVisible();
const screenUrl = page.url();
const catalog = await holdNextPluginCatalogResponse(page);
await page.reload();
const releaseCatalog = await catalog.response;
try {
await expect(
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
).toBeVisible();
await expect(
page.getByText("This plugin surface is unavailable.", { exact: true }),
).toHaveCount(0);
await expect(body).toHaveCount(0);
} finally {
releaseCatalog();
}
await expect(body).toBeVisible();
await expect(
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
).toHaveCount(0);
await expect(page).toHaveURL(screenUrl);
});
test("a plugin's screen, header row, footer popover and command work together", async ({
page,
}) => {
+13 -24
View File
@@ -2,7 +2,6 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
@@ -69,8 +68,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await openSettingsSection(page, "appearance");
const sectionTitle = page.getByText("Theme", { exact: true }).first();
await page.getByLabel("Theme: System", { exact: true }).click();
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
const mochaItem = page.getByRole("radio", { name: "Catppuccin Mocha", exact: true });
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker.png"),
@@ -79,14 +77,14 @@ test("applies a contributed theme and falls back when its plugin is gone", async
});
await test.step("a contributed light theme uses the light palette", async () => {
await page.getByText("Catppuccin Latte", { exact: true }).click();
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
const latteItem = page.getByRole("radio", { name: "Catppuccin Latte", exact: true });
await latteItem.click();
await expect(latteItem).toBeChecked();
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
});
await mochaItem.click();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
await expect(mochaItem).toBeChecked();
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-applied.png"),
@@ -95,15 +93,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await test.step("the selection survives a reload", async () => {
await page.reload();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mochaItem).toBeChecked({ timeout: 30_000 });
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
});
await test.step("removing the plugin falls back to the default theme", async () => {
await client.removePlugin(PLUGIN_ID);
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked({
timeout: 30_000,
});
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
@@ -147,15 +143,7 @@ const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
return () => {};
}`;
async function scrollThemeMenuToLastTheme(page: Page) {
const firstTheme = page.getByText("Pack theme 1", { exact: true });
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
await firstTheme.hover();
await page.mouse.wheel(0, 4000);
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
}
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
test("reaches the last theme when a plugin contributes more themes than fit", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
@@ -175,15 +163,16 @@ test("scrolls to the last theme when a plugin contributes more themes than fit",
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
await page.getByLabel("Theme: System", { exact: true }).click();
await scrollThemeMenuToLastTheme(page);
const lastTheme = page.getByRole("radio", { name: LAST_THEME_NAME, exact: true });
await expect(lastTheme).toBeAttached({ timeout: 30_000 });
await lastTheme.scrollIntoViewIfNeeded();
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
animations: "disabled",
});
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
await lastTheme.click();
await expect(lastTheme).toBeChecked();
} finally {
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
await client
@@ -262,12 +262,15 @@ test.describe("Settings host page", () => {
});
});
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
test("restart recovery defaults off and persists opt-in and custom prompt", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
await expectRestartRecoveryEnabled(page, false);
await setRestartRecoveryEnabled(page, true);
await page.reload();
await expectRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Resume working.");
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
@@ -326,12 +326,12 @@ test.describe("Settings sidebar navigation", () => {
await test.step("a dropdown owns Escape", async () => {
await openSettingsSection(page, "appearance");
await page.getByLabel(/Theme:/).click();
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
await page.getByLabel(/Highlight theme:/).click();
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
@@ -38,12 +38,9 @@ async function expectDarkIcons(expected, context) {
assert.equal(windowState(), expected, context);
}
// Settings > Appearance shows every theme as a tile in one radio group.
function selectTheme(theme) {
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
assert(current, "Start on Settings > Appearance");
ad("press", `label="Theme: ${current}"`, "--settle");
ad("press", `label="${theme}"`, "--settle");
ad("wait", "text", `Theme: ${theme}`, "5000");
}
ad("settings", "appearance", "light");
@@ -0,0 +1,54 @@
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
// Leave the daemon running. This test observes the public reconnecting state.
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
assert(
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
"Pass all five arguments",
);
const scale = Number(coordinateScale);
assert(scale > 0, "Pass a positive coordinate scale");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
encoding: "utf8",
timeout: 90_000,
});
}
function capture(name) {
const snapshot = ad("snapshot", "--raw", "--json");
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
ad("screenshot", path.join(artifacts, `${name}.png`));
const nodes = JSON.parse(snapshot).data.nodes;
const header = nodes.find((node) => node.identifier === "floating-header");
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
return toast.rect.y / scale;
}
ad("wait", 'id="agent-reconnecting-toast"', "20000");
const closedTop = capture("keyboard-closed");
ad("press", `label=${JSON.stringify(composerLabel)}`);
const opened = ad("snapshot", "--raw", "--json");
const nodes = JSON.parse(opened).data.nodes;
assert(
nodes.some(
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
),
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
);
const openTop = capture("keyboard-open");
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
console.log(
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
);
+13 -2
View File
@@ -107,6 +107,8 @@ const daemonTest = metroTest.extend<
});
const test = daemonTest.extend<{
/** Specs start past first-run setup; onboarding specs opt out to see a fresh install. */
e2eOnboardingCompleted: boolean;
paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
@@ -114,8 +116,9 @@ const test = daemonTest.extend<{
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
e2eOnboardingCompleted: [true, { option: true }],
paseoE2ESetup: [
async ({ page }, provide, testInfo) => {
async ({ page, e2eOnboardingCompleted }, provide, testInfo) => {
const daemonPort = getE2EDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -157,7 +160,7 @@ const test = daemonTest.extend<{
const createAgentPreferences = buildCreateAgentPreferences();
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
({ daemon, preferences, seedNonce: nonce, extraHostsKey, onboardingCompleted }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
@@ -185,12 +188,20 @@ const test = daemonTest.extend<{
if (!localStorage.getItem("@paseo:app-settings")) {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
}
// The shared daemon often has no projects, which would open first-run setup over the app.
if (onboardingCompleted && !localStorage.getItem("@paseo:onboarding")) {
localStorage.setItem(
"@paseo:onboarding",
JSON.stringify({ completedAt: new Date(0).toISOString() }),
);
}
},
{
daemon: testDaemon,
preferences: createAgentPreferences,
seedNonce,
extraHostsKey: EXTRA_HOSTS_KEY,
onboardingCompleted: e2eOnboardingCompleted,
},
);
@@ -154,7 +154,7 @@ export async function chooseCustomSkills(page: Page, selected: string[]): Promis
await all.click();
const keep = new Set(selected);
for (const name of await listSkillChoices(page)) {
if (!keep.has(name)) await page.getByRole("checkbox", { name, exact: true }).click();
if (!keep.has(name)) await skillSwitch(page, name).click();
}
await expectSkillChoices(page, selected);
}
@@ -176,7 +176,7 @@ export async function cancelSkillSelection(page: Page): Promise<void> {
}
export async function toggleSkill(page: Page, name: string): Promise<void> {
await page.getByRole("checkbox", { name, exact: true }).click();
await skillSwitch(page, name).click();
}
export function answerNextRemovalWarning(
@@ -222,15 +222,21 @@ export async function expectSkillFile(
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
const expected = new Set(selected);
for (const name of await listSkillChoices(page)) {
await expect(page.getByRole("checkbox", { name, exact: true })).toBeChecked({
await expect(skillSwitch(page, name)).toBeChecked({
checked: expected.has(name),
});
}
}
/** One bundled skill's switch in the picker; "All skills" sits outside the list. */
function skillSwitch(page: Page, name: string) {
return page.getByTestId("skill-selection-list-card").getByRole("switch", { name, exact: true });
}
async function listSkillChoices(page: Page): Promise<string[]> {
return page
.getByRole("checkbox")
.getByTestId("skill-selection-list-card")
.getByRole("switch")
.evaluateAll((elements) =>
elements
.map((element) => element.getAttribute("aria-label") ?? "")
@@ -1,5 +1,5 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
export interface DirectoryBootstrapCounts {
agents: number;
@@ -294,7 +294,8 @@ function recordClientRequest(
directoryStarts.total[directory] += 1;
}
export async function installDaemonWebSocketGate(page: Page) {
/** Gates the shared E2E daemon, or the daemon on `port` for a spec that runs its own. */
export async function installDaemonWebSocketGate(page: Page, options: { port?: number } = {}) {
let acceptingConnections = true;
let reconnectWithFreshClient = false;
let suppressAgentStream = false;
@@ -445,7 +446,11 @@ export async function installDaemonWebSocketGate(page: Page) {
return true;
};
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const routePattern =
options.port === undefined
? daemonWsRoutePattern()
: wsRoutePatternForPort(String(options.port));
await page.routeWebSocket(routePattern, (ws) => {
if (!acceptingConnections) {
blockedConnectionCount += 1;
for (const resolve of blockedConnectionWaiters) resolve();
@@ -0,0 +1,44 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
function listingResponse(message: string | Buffer) {
try {
const envelope = JSON.parse(message.toString());
return envelope.message?.type === "file_explorer_response" ? envelope.message.payload : null;
} catch {
return null;
}
}
/** Delay one real directory response after the daemon has read its contents. */
export async function delayDirectoryListingResponse(page: Page, directoryPath: string) {
let held: { socket: WebSocketRoute; message: string | Buffer; requestId: string } | null = null;
let resolveHeld!: () => void;
const heldResponse = new Promise<void>((resolve) => {
resolveHeld = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
const server = socket.connectToServer();
socket.onMessage((message) => server.send(message));
server.onMessage((message) => {
const response = listingResponse(message);
if (!held && response?.mode === "list" && response.path === directoryPath) {
held = { socket, message, requestId: response.requestId };
resolveHeld();
return;
}
socket.send(message);
});
});
return {
waitUntilHeld: () => heldResponse,
release: async () => {
if (!held) throw new Error("Directory response must be held before release");
held.socket.send(held.message);
// Let the delivered reply update the UI before the user reopens the folder.
await page.evaluate(
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
},
};
}
@@ -25,6 +25,8 @@ export interface IsolatedHostDaemon {
}
export interface IsolatedHostDaemonOptions {
/** Written to the daemon's config.json; `mutableRelay` adds `daemon.relay` to it. */
config?: { daemon?: Record<string, unknown>; [key: string]: unknown };
environment?: NodeJS.ProcessEnv;
mutableRelay?: {
enabled: boolean;
@@ -93,6 +95,28 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
);
}
function withMutableRelay(
config: NonNullable<IsolatedHostDaemonOptions["config"]>,
relay: NonNullable<IsolatedHostDaemonOptions["mutableRelay"]>,
): NonNullable<IsolatedHostDaemonOptions["config"]> {
const endpoint =
relay.endpoint ??
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
return {
...config,
daemon: {
...config.daemon,
relay: {
enabled: relay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
};
}
export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
@@ -138,24 +162,13 @@ export async function startIsolatedHostDaemon(
throw error;
}
}
if (options.mutableRelay) {
const endpoint =
options.mutableRelay.endpoint ??
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
const config = options.mutableRelay
? withMutableRelay(options.config ?? {}, options.mutableRelay)
: options.config;
if (config) {
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
version: 1,
daemon: {
relay: {
enabled: options.mutableRelay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
})}\n`,
`${JSON.stringify({ version: 1, ...config })}\n`,
);
}
const serverDir = publishedPackageRoot
@@ -0,0 +1,230 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { addProjectFlow, addProjectFlowInput, chooseAddProjectMethod } from "./add-project-flow";
import { connectDaemonClient } from "./daemon-client-loader";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { startIsolatedHostDaemon, type IsolatedHostDaemonOptions } from "./isolated-host-daemon";
/** Points a fresh browser profile at one host, with no first-run record and no app settings. */
export async function openAppAsNewUser(
page: Page,
host: { serverId: string; port: number },
): Promise<void> {
const seededHost = buildSeededHost({
serverId: host.serverId,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.route(/:6767\b/, (route) => route.abort());
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
});
await page.addInitScript(
({ registryHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([registryHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ registryHost: seededHost, preferences: buildCreateAgentPreferences() },
);
await page.goto("/");
}
export function setupModal(page: Page): Locator {
return page.getByTestId("onboarding-modal").getByRole("dialog");
}
export async function expectSetupStep(page: Page, title: string): Promise<void> {
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
timeout: 60_000,
});
}
export async function pickTheme(page: Page, name: string): Promise<void> {
const tile = setupModal(page).getByRole("radio", { name, exact: true });
await tile.click();
await expect(tile).toBeChecked();
}
export async function continueSetup(page: Page): Promise<void> {
await setupModal(page).getByRole("button", { name: "Continue", exact: true }).click();
}
export async function skipSetup(page: Page): Promise<void> {
await setupModal(page).getByRole("button", { name: "Skip setup", exact: true }).click();
await expect(setupModal(page)).toHaveCount(0);
}
/** Adds a folder through the regular Add project flow, opened from the setup's Projects step. */
export async function addProjectFromSetup(page: Page, projectPath: string): Promise<void> {
await setupModal(page).getByRole("button", { name: "Add project", exact: true }).click();
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
await chooseAddProjectMethod(page, "directory-search");
await addProjectFlowInput(page).fill(projectPath);
await page.keyboard.press("Enter");
await expect(addProjectFlow(page)).toHaveCount(0, { timeout: 30_000 });
}
export async function showSetupAgainFromSettings(page: Page): Promise<void> {
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await page.getByRole("button", { name: "Show setup again", exact: true }).click();
}
export async function goToSetupStep(page: Page, label: string): Promise<void> {
await setupModal(page).getByRole("tab", { name: label, exact: true }).click();
}
export function paseoToolsSwitch(scope: Page | Locator): Locator {
return scope.getByRole("switch", { name: "Inject Paseo tools", exact: true });
}
/** Whether the host gives its agents Paseo tools, read from the host itself. */
export async function hostGivesAgentsPaseoTools(host: { port: number }): Promise<boolean> {
const client = await connectDaemonClient<
Pick<InternalDaemonClient, "connect" | "close" | "getDaemonConfig">
>({ clientIdPrefix: "app-e2e-onboarding", port: host.port });
try {
return (await client.getDaemonConfig()).config.mcp.injectIntoAgents !== false;
} finally {
await client.close();
}
}
export async function finishSetup(page: Page): Promise<void> {
await setupModal(page).getByRole("button", { name: "Finish", exact: true }).click();
await expect(setupModal(page)).toHaveCount(0);
}
/** Opens the Phone step's Relay row: the pair device modal, over setup. */
export async function openRelayPairingFromSetup(page: Page): Promise<Locator> {
await setupModal(page).getByRole("button", { name: "Relay", exact: true }).click();
const pairing = page.getByTestId("onboarding-pair-device-card");
await expect(pairing).toBeVisible();
return pairing;
}
export async function closeRelayPairing(pairing: Locator): Promise<void> {
await pairing.getByRole("button", { name: "Close", exact: true }).click();
await expect(pairing).toHaveCount(0);
}
/** Clicks the Phone step's Tailscale row and returns the docs page it opened. */
export async function openTailscaleDocsFromSetup(page: Page): Promise<string> {
await page
.context()
.route("https://paseo.sh/**", (route) =>
route.fulfill({ contentType: "text/html", body: "<title>Paseo docs</title>" }),
);
const popup = page.waitForEvent("popup");
await setupModal(page).getByRole("link", { name: "Tailscale, Docs", exact: true }).click();
const docs = await popup;
await docs.waitForLoadState("domcontentloaded");
const url = docs.url();
await docs.close();
return url;
}
export interface SetupHost {
serverId: string;
port: number;
close(): Promise<void>;
}
/**
* An isolated host whose only agent history is the Claude sessions given here, so suggestions
* never depend on the machine running the test. Its HOME is a scratch folder, so the skills setup
* installs never land in the real ~/.agents, ~/.claude or ~/.codex. `withBrokenProvider` adds a
* provider that cannot start.
*/
export async function startSetupHost(
label: string,
options: {
claudeSessionsIn?: readonly string[];
withBrokenProvider?: boolean;
mutableRelay?: IsolatedHostDaemonOptions["mutableRelay"];
} = {},
): Promise<SetupHost> {
const serverId = `srv_onboarding_${label}_${randomUUID().replaceAll("-", "").slice(0, 8)}`;
const claudeConfigDir = await seedClaudeHistory(options.claudeSessionsIn ?? []);
const home = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-home-"));
const daemon = await startIsolatedHostDaemon(serverId, {
config: claudeOnlyConfig({ withBrokenProvider: options.withBrokenProvider ?? false }),
environment: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: claudeConfigDir },
mutableRelay: options.mutableRelay,
});
return {
serverId,
port: daemon.port,
close: async () => {
await daemon.close();
await rm(claudeConfigDir, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
},
};
}
/** Writes one recent Claude session per folder into a fresh CLAUDE_CONFIG_DIR. */
async function seedClaudeHistory(cwds: readonly string[]): Promise<string> {
const configDir = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-claude-"));
const newest = Date.now() - 60_000;
for (const [index, cwd] of cwds.entries()) {
const sessionId = `onboarding-session-${index + 1}`;
const projectDir = path.join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
await mkdir(projectDir, { recursive: true });
const sessionPath = path.join(projectDir, `${sessionId}.jsonl`);
const record = {
type: "user",
uuid: `${sessionId}-user`,
message: { role: "user", content: `Work on session ${index + 1}` },
cwd,
sessionId,
};
await writeFile(sessionPath, `${JSON.stringify(record)}\n`);
const timestamp = new Date(newest - index * 60_000);
await utimes(sessionPath, timestamp, timestamp);
}
return configDir;
}
function claudeOnlyConfig(options: { withBrokenProvider: boolean }) {
const brokenProvider = {
"broken-acp": {
extends: "acp",
label: "Broken ACP",
command: ["missing-agent-command", "acp"],
},
};
return {
agents: {
providers: {
codex: { enabled: false },
copilot: { enabled: false },
omp: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
...(options.withBrokenProvider ? brokenProvider : {}),
},
},
};
}
/** The switch that picks a suggested project for adding. */
export function projectSuggestion(page: Page, name: string): Locator {
return setupModal(page).getByRole("switch", { name, exact: true });
}
/** The whole suggestion row, with its path, last use and any add error beside the switch. */
export function projectSuggestionRow(page: Page, name: string): Locator {
return setupModal(page)
.getByTestId("project-suggestion")
.filter({ has: page.getByRole("switch", { name, exact: true }) });
}
/** The footer's main button, which adds the checked suggestions when there are any. */
export function setupPrimaryButton(page: Page, label: string): Locator {
return setupModal(page).getByRole("button", { name: label, exact: true });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -8,7 +8,7 @@ import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import { Field } from "@/components/ui/form-field";
import { identityForeground } from "@/styles/identity-colors";
@@ -153,7 +153,7 @@ export function AgentProfileAppearanceField({
icon: string;
color: string;
onChange: (next: { icon: string; color: string }) => void;
size?: FieldControlSize;
size?: ControlSize;
disabled?: boolean;
testID?: string;
triggerTestID?: string;
@@ -6,7 +6,7 @@ import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { type FieldControlSize } from "@/components/ui/control-geometry";
import { type ControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import {
SelectField,
@@ -14,7 +14,6 @@ import {
type SelectFieldOption,
} from "@/components/ui/select-field";
import { Switch } from "@/components/ui/switch";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { AgentProfileAppearanceField } from "./agent-profile-appearance-field";
import type {
@@ -133,7 +132,7 @@ function OpenAgentProfileEditModal({
onSave,
}: AgentProfileEditModalProps & { onDismiss: () => void }): ReactElement {
const { t } = useTranslation();
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const controlSize: ControlSize = "md";
const snapshot = useMemo(
() => ({ mode, ...(profile ? { profile } : {}), ...(seed ? { seed } : {}) }),
[mode, profile, seed],
@@ -410,7 +409,7 @@ function AgentProfileFeatureRow({
feature: AgentFeature;
isFirst: boolean;
disabled: boolean;
size: FieldControlSize;
size: ControlSize;
model: AgentProfileFormModel;
}): ReactElement {
const rowStyle = useMemo(
@@ -103,7 +103,6 @@ export function AgentProfileRow({
<View style={styles.rowActions}>
<Button
variant="ghost"
size="sm"
leftIcon={moveUpIcon}
onPress={handleMoveUp}
disabled={isFirst}
@@ -112,7 +111,6 @@ export function AgentProfileRow({
/>
<Button
variant="ghost"
size="sm"
leftIcon={moveDownIcon}
onPress={handleMoveDown}
disabled={isLast}
@@ -121,7 +119,6 @@ export function AgentProfileRow({
/>
<Button
variant="ghost"
size="sm"
leftIcon={editIcon}
onPress={handleEdit}
accessibilityLabel={t("settings.host.agentProfiles.editProfile")}
@@ -129,7 +126,6 @@ export function AgentProfileRow({
/>
<Button
variant="ghost"
size="sm"
leftIcon={removeIcon}
onPress={handleRemove}
accessibilityLabel={t("settings.host.agentProfiles.remove")}
@@ -124,7 +124,6 @@ export function AgentProfilesSection({ serverId }: { serverId: string }): ReactE
() => (
<Button
variant="ghost"
size="sm"
leftIcon={addIcon}
onPress={handleAddOpen}
disabled={!profiles}
@@ -181,7 +180,7 @@ export function AgentProfilesSection({ serverId }: { serverId: string }): ReactE
<Text style={styles.emptyText} testID="agent-profiles-empty">
{t("settings.host.agentProfiles.emptyState")}
</Text>
<Button size="sm" leftIcon={addIcon} onPress={handleAddOpen} disabled={!profiles}>
<Button leftIcon={addIcon} onPress={handleAddOpen} disabled={!profiles}>
{t("settings.host.agentProfiles.newProfile")}
</Button>
</View>
+129 -169
View File
@@ -3,29 +3,27 @@ import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "expo-router/react-navigation";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
import { Settings2 } from "lucide-react-native";
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
import type { TFunction } from "i18next";
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { Switch } from "@/components/ui/switch";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { SkillSelectionSheet } from "./selection-sheet";
import { useAgentSkills } from "./use-agent-skills";
import { useAgentSkills, type AgentSkillsController } from "./use-agent-skills";
export { useAgentSkills, type AgentSkillsController } from "./use-agent-skills";
const OP_KIND_ORDER: Record<AgentSkillOperation["kind"], number> = {
add: 0,
update: 1,
delete: 2,
};
const ThemedBlocks = withUnistyles(Blocks);
const ThemedCheck = withUnistyles(Check);
const ThemedSettings = withUnistyles(Settings2);
const ThemedArrowUpRight = withUnistyles(ArrowUpRight);
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): string {
@@ -35,91 +33,50 @@ function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction):
.join("\n");
}
/** Whether the host has Paseo's skills installed in any form, current or not. */
export function areSkillsInstalled(status: AgentSkillsStatus | null): boolean {
return status !== null && status.state !== "not-installed";
}
/** Whether the saved selection names any skill there is to install. */
export function hasSelectedSkills(status: AgentSkillsStatus | null): boolean {
if (!status) return false;
if (status.selection.mode === "all") return status.available.length > 0;
return status.selection.skills.some((name) => status.available.includes(name));
}
export function AgentSkillsSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const skills = useAgentSkills(serverId);
const refreshSkills = skills.refresh;
const skillsConnected = skills.connected;
const skillsSupported = skills.supported;
const [isChoosing, setIsChoosing] = useState(false);
const { refresh, connected, supported, reconcile, uninstall } = skills;
useFocusEffect(
useCallback(() => {
if (skillsConnected && skillsSupported) void refreshSkills();
if (connected && supported) void refresh();
return undefined;
}, [refreshSkills, skillsConnected, skillsSupported]),
}, [refresh, connected, supported]),
);
const maintenanceOps = useMemo(
() => skills.status?.ops.filter((op) => op.kind !== "delete") ?? [],
[skills.status?.ops],
const handleValueChange = useCallback(
(next: boolean) => {
void (next ? reconcile() : uninstall());
},
[reconcile, uninstall],
);
const state =
skills.status?.state === "drift" && maintenanceOps.length === 0
? "up-to-date"
: (skills.status?.state ?? null);
const hasSelected =
(skills.status?.selection.mode === "all" && skills.status.available.length > 0) ||
(skills.status?.selection.mode === "custom" &&
skills.status.selection.skills.some((name) => skills.status?.available.includes(name)));
const handleReconcile = useCallback(async () => {
if (skills.isWorking) return;
const confirmed = await confirmDialog({
title: t("settings.host.skills.updateTitle"),
message:
maintenanceOps.length > 0
? formatUpdateMessage(maintenanceOps, t)
: t("settings.host.skills.updateFallback"),
confirmLabel: t("settings.host.skills.actions.update"),
});
if (confirmed) await skills.reconcile();
}, [maintenanceOps, skills, t]);
const handleUninstall = useCallback(async () => {
if (skills.isWorking) return;
const confirmed = await confirmDialog({
title: t("settings.host.skills.uninstallTitle"),
message: t("settings.host.skills.uninstallMessage"),
confirmLabel: t("settings.host.skills.actions.uninstall"),
destructive: true,
});
if (confirmed) await skills.uninstall();
}, [skills, t]);
const chooseIcon = useMemo(
() => <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedMapping} />,
[],
);
const handleOpen = useCallback(() => setIsChoosing(true), []);
const handleClose = useCallback(() => setIsChoosing(false), []);
const handleOpenDocs = useCallback(() => {
void openExternalUrl(SKILLS_DOCS_URL);
}, []);
const docsIcon = useMemo(
() => <ThemedArrowUpRight size={ICON_SIZE.sm} uniProps={mutedMapping} />,
[],
);
const trailing = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={docsIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenDocs}
accessibilityLabel={t("settings.host.skills.openDocs")}
>
{t("settings.host.skills.docs")}
</Button>
),
[docsIcon, handleOpenDocs, t],
const docsLink = useMemo(
() => ({
href: SKILLS_DOCS_URL,
label: t("settings.host.skills.docs"),
accessibilityLabel: t("settings.host.skills.openDocs"),
}),
[t],
);
if (!skills.connected || !skills.supported) {
if (!connected || !supported) {
return (
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
<SettingsSection title={t("settings.host.skills.sectionTitle")} link={docsLink}>
<View style={settingsStyles.card} testID="host-agent-skills-unavailable">
<View style={styles.emptyCard}>
<Text style={styles.mutedText}>
{skills.connected
{connected
? t("settings.host.skills.unsupported")
: t("settings.host.skills.unavailable")}
</Text>
@@ -130,109 +87,112 @@ export function AgentSkillsSection({ serverId }: { serverId: string }) {
}
return (
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
<View style={settingsStyles.card} testID="host-agent-skills-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<ThemedBlocks size={ICON_SIZE.md} uniProps={foregroundMapping} />
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.title")}</Text>
</View>
<Text style={settingsStyles.rowHint}>
{state === "drift"
? t("settings.host.skills.updateAvailable")
: t("settings.host.skills.description")}
</Text>
</View>
<View style={styles.actionsRow}>
<Button
variant="ghost"
size="sm"
leftIcon={chooseIcon}
onPress={handleOpen}
disabled={skills.status === null || skills.isWorking}
accessibilityLabel={t("settings.host.skills.choose")}
/>
{hasSelected ? (
<SkillsActions
state={state}
isWorking={skills.isWorking}
onInstall={skills.reconcile}
onReconcile={handleReconcile}
onUninstall={handleUninstall}
/>
) : null}
</View>
</View>
</View>
{skills.status ? (
<SkillSelectionSheet
visible={isChoosing}
available={skills.status.available}
selection={skills.status.selection}
isSaving={skills.isWorking}
onSave={skills.saveSelection}
onClose={handleClose}
<SettingsSection title={t("settings.host.skills.sectionTitle")} link={docsLink}>
<SettingsCard testID="host-agent-skills-card">
<AgentSkillsRow
skills={skills}
value={areSkillsInstalled(skills.status)}
onValueChange={handleValueChange}
/>
) : null}
</SettingsCard>
</SettingsSection>
);
}
function SkillsActions({
state,
isWorking,
onReconcile,
onInstall,
onUninstall,
/**
* The orchestration skills row: a switch for whether they are installed, a gear that opens the
* skill picker, and Update when the installed skills fall behind the bundled ones. The caller
* decides what the switch is bound to; the picker and Update always act on the host.
*/
export function AgentSkillsRow({
skills,
value,
onValueChange,
disabled = false,
}: {
state: AgentSkillsStatus["state"] | null;
isWorking: boolean;
onReconcile: () => void;
onInstall: () => void;
onUninstall: () => void;
skills: AgentSkillsController;
value: boolean;
onValueChange: (next: boolean) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
if (state === "up-to-date") {
return (
<View style={styles.actionsRow}>
<View style={styles.installedLabel}>
<ThemedCheck size={14} uniProps={mutedMapping} />
<Text style={styles.mutedText}>{t("settings.host.skills.actions.installed")}</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.host.skills.actions.uninstall")}
</Button>
</View>
);
}
if (state === "drift") {
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onReconcile} disabled={isWorking}>
{isWorking
? t("settings.host.skills.actions.working")
: t("settings.host.skills.actions.update")}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.host.skills.actions.uninstall")}
</Button>
</View>
);
}
const { status, isWorking, reconcile, saveSelection } = skills;
const [isChoosing, setIsChoosing] = useState(false);
// Deleting a deselected skill is a choice made in the picker, not an update to offer here.
const maintenanceOps = useMemo(
() => status?.ops.filter((op) => op.kind !== "delete") ?? [],
[status?.ops],
);
const needsUpdate = status?.state === "drift" && maintenanceOps.length > 0;
const handleUpdate = useCallback(async () => {
if (isWorking) return;
const confirmed = await confirmDialog({
title: t("settings.host.skills.updateTitle"),
message: formatUpdateMessage(maintenanceOps, t),
confirmLabel: t("settings.host.skills.actions.update"),
});
if (confirmed) await reconcile();
}, [isWorking, maintenanceOps, reconcile, t]);
const handleOpen = useCallback(() => setIsChoosing(true), []);
const handleClose = useCallback(() => setIsChoosing(false), []);
const chooseIcon = useMemo(
() => <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedMapping} />,
[],
);
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking
? t("settings.host.skills.actions.installing")
: t("settings.host.skills.actions.install")}
</Button>
<>
<SettingsRow
label={t("settings.host.skills.title")}
hint={
needsUpdate
? t("settings.host.skills.updateAvailable")
: t("settings.host.skills.description")
}
>
<View style={styles.controls}>
{needsUpdate ? (
<Button variant="outline" onPress={handleUpdate} disabled={disabled || isWorking}>
{isWorking
? t("settings.host.skills.actions.working")
: t("settings.host.skills.actions.update")}
</Button>
) : null}
<Button
variant="ghost"
leftIcon={chooseIcon}
onPress={handleOpen}
disabled={disabled || status === null || isWorking}
accessibilityLabel={t("settings.host.skills.choose")}
/>
<Switch
value={value}
onValueChange={onValueChange}
// Nothing picked means nothing to install; the gear is the way forward.
disabled={
disabled || status === null || isWorking || (!value && !hasSelectedSkills(status))
}
accessibilityLabel={t("settings.host.skills.title")}
/>
</View>
</SettingsRow>
{status ? (
<SkillSelectionSheet
visible={isChoosing}
available={status.available}
selection={status.selection}
isSaving={isWorking}
onSave={saveSelection}
onClose={handleClose}
/>
) : null}
</>
);
}
const styles = StyleSheet.create((theme) => ({
rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 },
controls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
actionsRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
emptyCard: { padding: theme.spacing[4] },
}));
@@ -1,23 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import type {
AgentSkillSelection as SkillSelection,
AgentSkillsSaveResult as SkillsSaveResult,
} from "@getpaseo/protocol/messages";
const ThemedCheck = withUnistyles(Check);
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
interface SkillSelectionSheetProps {
visible: boolean;
available: readonly string[];
@@ -157,12 +152,11 @@ export function SkillSelectionSheet({
footer={footer}
testID="skill-selection-sheet"
>
<View style={settingsStyles.card} testID="skill-selection-all-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.chooseAll")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseAllHint")}</Text>
</View>
<SettingsCard testID="skill-selection-all-card">
<SettingsRow
label={t("settings.host.skills.chooseAll")}
hint={t("settings.host.skills.chooseAllHint")}
>
<Switch
value={allSkills}
onValueChange={handleToggleAll}
@@ -170,30 +164,29 @@ export function SkillSelectionSheet({
accessibilityLabel={t("settings.host.skills.chooseAll")}
testID="skill-selection-all"
/>
</View>
</View>
</SettingsRow>
</SettingsCard>
{/* The sheet body already spaces its blocks apart, so the section owns
only the gap between its label and the list the label names. */}
<SettingsSection title={t("settings.host.skills.chooseList")} flush>
<View style={settingsStyles.card} testID="skill-selection-list-card">
<SettingsCard testID="skill-selection-list-card">
{available.length === 0 ? (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseEmpty")}</Text>
</View>
) : (
available.map((name, index) => (
<SkillCheckboxRow
available.map((name) => (
<SkillRow
key={name}
name={name}
checked={isSkillSelected(draft, name)}
disabled={allSkills || isSaving}
withBorder={index > 0}
onToggle={handleToggleSkill}
/>
))
)}
</View>
</SettingsCard>
</SettingsSection>
{saveError ? <Text style={settingsStyles.rowError}>{saveError}</Text> : null}
@@ -201,52 +194,27 @@ export function SkillSelectionSheet({
);
}
interface SkillCheckboxRowProps {
name: string;
checked: boolean;
disabled: boolean;
withBorder: boolean;
onToggle: (name: string) => void;
}
function SkillCheckboxRow({
function SkillRow({
name,
checked,
disabled,
withBorder,
onToggle,
}: SkillCheckboxRowProps) {
const handlePress = useCallback(() => onToggle(name), [name, onToggle]);
const accessibilityState = useMemo(() => ({ checked, disabled }), [checked, disabled]);
const rowStyle = useMemo(
() => [
settingsStyles.row,
withBorder ? settingsStyles.rowBorder : null,
styles.checkboxRow,
disabled ? styles.disabledRow : null,
],
[disabled, withBorder],
);
const boxStyle = useMemo(
() => [styles.checkbox, checked ? styles.checkboxChecked : null],
[checked],
);
}: {
name: string;
checked: boolean;
disabled: boolean;
onToggle: (name: string) => void;
}) {
const handleValueChange = useCallback(() => onToggle(name), [name, onToggle]);
return (
<Pressable
style={rowStyle}
onPress={handlePress}
disabled={disabled}
accessibilityRole="checkbox"
accessibilityLabel={name}
accessibilityState={accessibilityState}
aria-checked={checked}
>
<View style={boxStyle}>
{checked ? <ThemedCheck size={14} uniProps={checkedIconMapping} /> : null}
</View>
<Text style={settingsStyles.rowTitle}>{name}</Text>
</Pressable>
<SettingsRow label={name}>
<Switch
value={checked}
onValueChange={handleValueChange}
disabled={disabled}
accessibilityLabel={name}
/>
</SettingsRow>
);
}
@@ -259,24 +227,4 @@ const styles = StyleSheet.create((theme) => ({
footerButton: {
flex: 1,
},
checkboxRow: {
justifyContent: "flex-start",
gap: theme.spacing[3],
},
disabledRow: {
opacity: theme.opacity[50],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
}));
@@ -10,6 +10,9 @@ import { useToast } from "@/contexts/toast-context";
import { useFetchQuery } from "@/data/query";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { confirmDialog } from "@/utils/confirm-dialog";
export type AgentSkillsController = ReturnType<typeof useAgentSkills>;
export function useAgentSkills(serverId: string) {
const { t } = useTranslation();
@@ -83,12 +86,30 @@ export function useAgentSkills(serverId: string) {
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
const runReconcile = useCallback(async () => {
await reconcileAsync().catch(() => undefined);
}, [reconcileAsync]);
// Both resolve to whether the host ended in the asked-for state; failures are already toasted.
const runReconcile = useCallback(
() =>
reconcileAsync().then(
(status) => status.state !== "not-installed",
() => false,
),
[reconcileAsync],
);
// Uninstalling deletes whole skill folders, including anything the user added inside them, so
// it always asks first. Declining resolves false, as a failure does.
const runUninstall = useCallback(async () => {
await uninstallAsync().catch(() => undefined);
}, [uninstallAsync]);
const confirmed = await confirmDialog({
title: t("settings.host.skills.uninstallTitle"),
message: t("settings.host.skills.uninstallMessage"),
confirmLabel: t("settings.host.skills.actions.uninstall"),
destructive: true,
});
if (!confirmed) return false;
return uninstallAsync().then(
(status) => status.state === "not-installed",
() => false,
);
}, [t, uninstallAsync]);
const saveSelection = useCallback(
(selection: AgentSkillSelection, confirmedRemovals: readonly string[] = []) =>
saveAsync({ selection, confirmedRemovals }),
@@ -147,7 +147,7 @@ export function ChatFind({
{state.failure && (
<View style={styles.error}>
<Text style={styles.errorText}>{t(FAILURE_TEXT[state.failure])}</Text>
<Button size="xs" variant="ghost" onPress={model.retry}>
<Button size="sm" variant="ghost" onPress={model.retry}>
{t("paneFind.retry")}
</Button>
</View>
+2
View File
@@ -36,6 +36,7 @@ import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { ChangelogHost } from "@/changelog";
import { OnboardingHost } from "@/onboarding";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
@@ -496,6 +497,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<OnboardingHost />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
@@ -0,0 +1,292 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { PluginThemeOption } from "@/plugins/themes";
import {
THEME_OPTIONS,
PLUGIN_THEME_PREFERENCE,
darkTheme,
lightTheme,
type Theme,
} from "@/styles/theme";
import { useContributedThemes } from "./provider";
type BuiltInThemePreference = Exclude<AppSettings["theme"], typeof PLUGIN_THEME_PREFERENCE>;
// System leads the grid because it is the default.
const PICKER_THEME_OPTIONS = [
...THEME_OPTIONS.filter((option) => option.name === "auto"),
...THEME_OPTIONS.filter((option) => option.name !== "auto"),
];
const PREVIEW_ASPECT_RATIO = 16 / 9;
/**
* Every theme as a tile with a small preview drawn from its own tokens. Picking a tile applies
* the theme to the whole app at once. Settings → Appearance and first-run setup share it.
*/
export function ThemePicker() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const { options: pluginThemes, selected: selectedPluginTheme, select } = useContributedThemes();
// A selected contribution that is no longer installed shows the fallback the app renders.
const builtInValue =
settings.theme === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : settings.theme;
const selectBuiltIn = useCallback(
(theme: BuiltInThemePreference) => {
void updateSettings({ theme });
},
[updateSettings],
);
return (
<View
style={styles.grid}
accessibilityRole="radiogroup"
accessibilityLabel={t("settings.appearance.theme.title")}
>
{PICKER_THEME_OPTIONS.map((option) => (
<BuiltInThemeTile
key={option.name}
value={option.name}
selected={selectedPluginTheme === null && builtInValue === option.name}
onSelect={selectBuiltIn}
/>
))}
{pluginThemes.map((option) => (
<PluginThemeTile
key={option.id}
option={option}
selected={selectedPluginTheme?.id === option.id}
onSelect={select}
/>
))}
</View>
);
}
function BuiltInThemeTile({
value,
selected,
onSelect,
}: {
value: BuiltInThemePreference;
selected: boolean;
onSelect: (value: BuiltInThemePreference) => void;
}) {
const { t } = useTranslation();
const handlePress = useCallback(() => onSelect(value), [onSelect, value]);
const option = THEME_OPTIONS.find((candidate) => candidate.name === value);
return (
<ThemeTile
label={t(`settings.appearance.theme.options.${value}`)}
selected={selected}
onPress={handlePress}
previewTheme={option && "theme" in option ? option.theme : "system"}
/>
);
}
function PluginThemeTile({
option,
selected,
onSelect,
}: {
option: PluginThemeOption;
selected: boolean;
onSelect: (option: PluginThemeOption) => void;
}) {
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
return (
<ThemeTile
label={option.name}
selected={selected}
onPress={handlePress}
previewTheme={option.theme}
/>
);
}
function ThemeTile({
label,
selected,
onPress,
previewTheme,
}: {
label: string;
selected: boolean;
onPress: () => void;
/** "system" follows the OS, so its preview shows both halves. */
previewTheme: Theme | "system";
}) {
const accessibilityState = useMemo(() => ({ checked: selected }), [selected]);
const tileStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.tile,
hovered && !selected && styles.tileHovered,
selected && styles.tileSelected,
],
[selected],
);
return (
<View style={styles.cell}>
<PressHighlight
style={tileStyle}
onPress={onPress}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={selected}
>
<View style={styles.previewFrame}>
{previewTheme === "system" ? (
<SystemThemePreview />
) : (
<ThemePreview theme={previewTheme} />
)}
</View>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
</View>
);
}
/** A sketch of the app — sidebar, a few lines of text and an accent bubble — in `theme`'s colors. */
function ThemePreview({ theme }: { theme: Theme }) {
const colors = theme.colors;
const previewStyles = useMemo(
() => ({
root: [styles.preview, { backgroundColor: colors.surface0 }],
sidebar: [styles.previewSidebar, { backgroundColor: colors.surfaceSidebar }],
mutedLine: { backgroundColor: colors.foregroundMuted },
strongLine: { backgroundColor: colors.foreground },
bubble: [styles.previewBubble, { backgroundColor: colors.accent }],
}),
[colors],
);
return (
<View style={previewStyles.root}>
<View style={previewStyles.sidebar}>
<View style={[styles.previewLine, styles.sidebarLineWide, previewStyles.mutedLine]} />
<View style={[styles.previewLine, styles.sidebarLineNarrow, previewStyles.mutedLine]} />
<View style={[styles.previewLine, styles.sidebarLineMedium, previewStyles.mutedLine]} />
</View>
<View style={styles.previewMain}>
<View style={[styles.previewTitle, previewStyles.strongLine]} />
<View style={[styles.previewLine, styles.mainLineWide, previewStyles.mutedLine]} />
<View style={[styles.previewLine, styles.mainLineNarrow, previewStyles.mutedLine]} />
<View style={previewStyles.bubble} />
</View>
</View>
);
}
/** "System" follows the OS: light on the left half, dark on the right. */
function SystemThemePreview() {
return (
<View style={styles.preview}>
<ThemePreview theme={lightTheme} />
<View style={styles.systemDarkHalf}>
<View style={styles.systemDarkOffset}>
<ThemePreview theme={darkTheme} />
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
// Columns fill the row: each cell is a fixed fraction of it, and the half-gap padding on every
// cell plus the negative margin here leaves exactly one gap between neighbors.
grid: {
flexDirection: "row",
flexWrap: "wrap",
margin: -theme.spacing[3] / 2,
},
cell: {
width: { xs: "50%", md: "25%" },
padding: theme.spacing[3] / 2,
},
tile: {
padding: theme.spacing[1],
gap: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[2],
borderColor: "transparent",
},
tileHovered: {
backgroundColor: theme.colors.surface2,
},
tileSelected: {
borderColor: theme.colors.accent,
},
previewFrame: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
},
label: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[1],
},
preview: {
width: "100%",
aspectRatio: PREVIEW_ASPECT_RATIO,
flexDirection: "row",
overflow: "hidden",
},
previewSidebar: {
width: 36,
paddingHorizontal: 6,
paddingVertical: 8,
gap: 5,
},
previewMain: {
flex: 1,
padding: 8,
gap: 5,
},
previewLine: {
height: 4,
borderRadius: 2,
opacity: 0.6,
},
previewTitle: {
width: "55%",
height: 5,
borderRadius: 2,
},
sidebarLineWide: { width: "100%" },
sidebarLineNarrow: { width: "60%" },
sidebarLineMedium: { width: "80%" },
mainLineWide: { width: "90%" },
mainLineNarrow: { width: "65%" },
previewBubble: {
marginTop: "auto",
alignSelf: "flex-end",
width: "45%",
height: 10,
borderRadius: 5,
},
systemDarkHalf: {
position: "absolute",
top: 0,
bottom: 0,
left: "50%",
width: "50%",
overflow: "hidden",
},
// A full-width dark preview shifted left by half, so only its right half shows.
systemDarkOffset: {
width: "200%",
marginLeft: "-100%",
},
}));
@@ -118,7 +118,7 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB
description={t("changelog.error.description")}
testID="changelog-error"
>
<Button variant="outline" size="sm" onPress={onRetry} testID="changelog-retry">
<Button variant="outline" onPress={onRetry} testID="changelog-retry">
{t("common.actions.retry")}
</Button>
</Alert>
@@ -99,7 +99,7 @@ const styles = StyleSheet.create((theme) => ({
maxHeight: "85%",
flexShrink: 1,
minHeight: 0,
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.surface2,
@@ -140,6 +140,12 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
borderRadius: theme.borderRadius.lg,
},
// Without a header the close button floats over the card's top-right corner.
floatingClose: {
position: "absolute",
top: theme.spacing[4],
right: theme.spacing[4],
},
searchRow: {
flexDirection: "row",
alignItems: "center",
@@ -493,8 +499,27 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
);
}
/**
* The sheet's action row: a top border, the sheet's side inset, and children spread across it.
* The sheet draws it for `footer`; a body that splits into panes renders it at the bottom of one.
*/
export function SheetFooter({
style,
children,
}: {
style?: StyleProp<ViewStyle>;
children: ReactNode;
}) {
return <View style={[styles.footer, style]}>{children}</View>;
}
export interface AdaptiveModalSheetProps {
header: SheetHeader;
/**
* The title row with the close button. Leave it out when the body draws its own chrome, such as
* a full-height side rail: the desktop card then floats the close button over its top-right
* corner, and the compact sheet closes from its handle.
*/
header?: SheetHeader;
visible: boolean;
onClose: () => void;
onDismiss?: () => void;
@@ -534,7 +559,7 @@ function CompactSheetBody({
contentStyle,
children,
}: {
header: SheetHeader;
header?: SheetHeader;
onClose: () => void;
testID?: string;
footer: ReactNode;
@@ -564,7 +589,7 @@ function CompactSheetBody({
);
return (
<SheetVisibleFrame footer={footerView}>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{header ? <SheetHeaderView header={header} onClose={onClose} testID={testID} /> : null}
<View style={[styles.compactStaticContent, bodyStyle]}>
{scrollable ? (
<ScrollView
@@ -612,7 +637,7 @@ export function AdaptiveModalSheet({
const isKeyboardVisible = useKeyboardVisibility(visible);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const footerView = useMemo(
() => (footer ? <View style={[styles.footer, footerContainerStyle]}>{footer}</View> : null),
() => (footer ? <SheetFooter style={footerContainerStyle}>{footer}</SheetFooter> : null),
[footer, footerContainerStyle],
);
useEffect(() => {
@@ -730,7 +755,7 @@ export function AdaptiveModalSheet({
desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent;
const cardInner = (
<OverlayLayerProvider layer={modalLayer}>
<SheetHeaderView header={header} onClose={onClose} />
{header ? <SheetHeaderView header={header} onClose={onClose} /> : null}
<View style={[scrollable ? styles.desktopScrollContainer : desktopStaticStyle, bodyStyle]}>
{scrollable ? (
<ScrollView
@@ -746,6 +771,15 @@ export function AdaptiveModalSheet({
)}
</View>
{footerView}
{header ? null : (
<View style={styles.floatingClose}>
<SheetHeaderIconButton
icon={X}
accessibilityLabel={t("common.actions.close")}
onPress={onClose}
/>
</View>
)}
</OverlayLayerProvider>
);
@@ -41,7 +41,6 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
@@ -27,7 +27,6 @@ export function CommunityLinks() {
<View style={styles.row}>
<Button
variant="ghost"
size="sm"
leftIcon={renderGitHubIcon}
onPress={handleOpenGitHub}
testID="community-links-github-star"
@@ -36,7 +35,6 @@ export function CommunityLinks() {
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={Heart}
onPress={handleOpenSponsor}
testID="community-links-sponsor"
@@ -45,7 +43,6 @@ export function CommunityLinks() {
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={renderDiscordIcon}
onPress={handleOpenDiscord}
testID="community-links-discord"
@@ -59,6 +56,9 @@ export function CommunityLinks() {
const styles = StyleSheet.create(() => ({
row: {
flexDirection: "row",
// Shrinks and wraps rather than running off a narrow phone screen.
flexShrink: 1,
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
gap: 0,
@@ -64,10 +64,15 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
DIRECTORY_LISTING_INVALIDATED,
type DirectoryListingResult,
} from "@/file-explorer/directory-listings";
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import {
flattenExplorerTree,
reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
@@ -435,6 +440,7 @@ export function FileExplorerPane({
const {
requestDirectoryListing,
invalidateDirectoryListings,
createEntry,
renameEntry,
duplicateEntry,
@@ -886,9 +892,11 @@ export function FileExplorerPane({
refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
showHiddenFiles: usePanelStore.getState().explorerShowHiddenFiles,
requestDirectoryListing,
invalidateDirectoryListings,
}),
[expandedPaths, hasWorkspaceScope, requestDirectoryListing],
[invalidateDirectoryListings, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
);
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
@@ -1424,7 +1432,7 @@ function toggleDirectory({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
) => Promise<DirectoryListingResult>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): void {
if (!workspaceStateKey) {
@@ -1561,7 +1569,7 @@ async function initializeExplorer({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
) => Promise<DirectoryListingResult>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): Promise<void> {
if (!hasWorkspaceScope || hasInitializedRef.current) {
@@ -1572,6 +1580,7 @@ async function initializeExplorer({
recordHistory: false,
setCurrentPath: false,
});
if (rootDirectory === DIRECTORY_LISTING_INVALIDATED) return;
if (!rootDirectory) {
hasInitializedRef.current = false;
return;
@@ -1593,7 +1602,10 @@ async function initializeExplorer({
const hiddenPersistedPaths = showHiddenFiles
? []
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
const restoredPathsWithHidden =
restoredPaths === DIRECTORY_LISTING_INVALIDATED
? DIRECTORY_LISTING_INVALIDATED
: [...restoredPaths, ...hiddenPersistedPaths];
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths,
@@ -1603,39 +1615,6 @@ async function initializeExplorer({
);
}
async function refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
requestDirectoryListing,
}: {
hasWorkspaceScope: boolean;
expandedPaths: Set<string>;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
}): Promise<null> {
if (!hasWorkspaceScope) {
return null;
}
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
const directoryPaths = Array.from(expandedPaths).filter(
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
);
if (!directoryPaths.includes(".")) {
directoryPaths.unshift(".");
}
await Promise.all(
directoryPaths.map((path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
),
);
return null;
}
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
if (!state) {
return ".";
@@ -1,8 +1,26 @@
import { useCallback, useState, type ReactNode } from "react";
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Portal, PortalHost } from "@gorhom/portal";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { EdgeFade } from "@/components/ui/edge-fade";
const OverlayClearanceContext = createContext(0);
const OverlayHostContext = createContext<string | null>(null);
/** Keep status overlays above the fade without lifting the scrolling content. */
export function FloatingHeaderOverlay({ children }: { children: ReactNode }) {
const hostName = useContext(OverlayHostContext);
const active = useRetainedPanelActive();
if (!hostName) return children;
return active ? <Portal hostName={hostName}>{children}</Portal> : null;
}
/** Clearance for stationary overlays inside this layout, measured to the header bottom. */
export function useFloatingHeaderOverlayClearance(): number {
return useContext(OverlayClearanceContext);
}
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
const FADE_OVERHANG = 48;
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
@@ -20,15 +38,24 @@ interface FloatingHeaderLayoutProps {
* same tree position in both modes, so toggling `floating` never remounts them.
*/
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
const overlayHostName = useId();
const [headerHeight, setHeaderHeight] = useState(0);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
}, []);
const overlayClearance = floating ? headerHeight : 0;
return (
<View style={styles.root}>
{floating ? null : header}
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
<View style={styles.content}>
<OverlayClearanceContext.Provider value={overlayClearance}>
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
{children(floating ? headerHeight : 0)}
</OverlayHostContext.Provider>
</OverlayClearanceContext.Provider>
</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none">
<EdgeFade
@@ -37,11 +64,19 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
backdrop="surfaceWorkspace"
style={styles.fade}
/>
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
<View
testID="floating-header"
collapsable={false}
pointerEvents="box-none"
onLayout={handleHeaderLayout}
>
{header}
</View>
</View>
) : null}
<View style={styles.statusOverlay} pointerEvents="box-none">
<PortalHost name={overlayHostName} />
</View>
</View>
);
}
@@ -55,6 +90,13 @@ const styles = StyleSheet.create({
flex: 1,
minHeight: 0,
},
statusOverlay: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
overlay: {
position: "absolute",
top: 0,
@@ -0,0 +1,9 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
// Same mark as the website's download buttons (packages/website/src/downloads.tsx).
const APPLE_PATH =
"M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701";
export function AppleIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} path={APPLE_PATH} viewBox="0 0 24 24" />;
}
@@ -0,0 +1,19 @@
import Svg, { Path } from "react-native-svg";
/**
* The four-colour Play mark, the same one as the website's download buttons
* (packages/website/src/downloads.tsx). Brand colours are fixed, so it takes no color.
*/
export function GooglePlayIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path d="M3.27 2.17c-.2.21-.32.55-.32 1v17.66c0 .45.12.79.33 1l9.87-9.84z" fill="#00F076" />
<Path
d="m16.43 8.72-3.28 3.27 3.29 3.28 3.9-2.22c1.13-.64 1.13-1.42 0-2.06z"
fill="#FFCE00"
/>
<Path d="m13.15 11.99 3.28-3.27L4.63 2.01c-.56-.32-1.04-.28-1.36.16z" fill="#00D6FF" />
<Path d="M3.28 21.83c.32.44.8.48 1.36.16l11.8-6.72-3.29-3.28z" fill="#FF3A44" />
</Svg>
);
}
@@ -206,7 +206,7 @@ function ProviderErrorBannerRow({
</Text>
<Button
variant="ghost"
size="xs"
size="sm"
onPress={handleRetry}
testID={`import-session-retry-${row.provider}`}
>
@@ -266,7 +266,7 @@ function ScopeSubtitle({
{isScoped ? (
<Button
variant="ghost"
size="xs"
size="sm"
onPress={onShowAll}
testID="import-session-show-all"
style={styles.subtitleAction}
@@ -38,12 +38,18 @@ export interface MarkdownImageContext {
export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
commandNames?: ReadonlySet<string>,
links: { commandNames?: ReadonlySet<string>; forgeReferencePattern?: string } = {},
) {
const occurrenceKey = useId();
const { commandNames, forgeReferencePattern } = links;
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
[text, context.workspaceRoot, commandNames],
() =>
prepareMarkdownSource(text, context.workspaceRoot, {
supportsSvg: isWeb,
commandNames,
forgeReferencePattern,
}),
[text, context.workspaceRoot, commandNames, forgeReferencePattern],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
@@ -3,6 +3,7 @@ import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/util
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { matchCommandLink } from "@/agent-commands";
import { createForgeShorthand } from "@/git/forge-shorthand";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
@@ -110,6 +111,37 @@ describe("shared Markdown source adaptation", () => {
["https://x/q.png", false],
]);
});
it("renders linked images that share a line with text inline", async () => {
// A review bot's summary header: linked badges inside a heading with the score text.
const reviewHeader =
'<h2><a href="https://x/retrigger"><picture><source media="(prefers-color-scheme: dark)" srcset="https://x/r-dark.svg"><img alt="Retrigger" src="https://x/r.svg" align="right"></picture></a>Confidence Score: 5/5</h2>';
const source = [
reviewHeader,
"[![Alone](https://x/alone.png)](https://x)",
"[![a](https://x/a.png)](https://x) [![b](https://x/b.png)](https://x)",
"See [![gh](https://x/gh.png) GitHub](https://x) for more.",
].join("\n\n");
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
markInlineImages(tree);
const images: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Image") images.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(
images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]),
).toEqual([
["https://x/r.svg", true],
["https://x/alone.png", false],
["https://x/a.png", true],
["https://x/b.png", true],
["https://x/gh.png", true],
]);
});
it("escapes spoiler delimiters only in prose", () => {
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
"Show \\|\\|words\\|\\| and `||code||`.",
@@ -412,6 +444,47 @@ it("lets the engine own code boundaries while classifying candidate values", asy
]);
});
it("recognizes forge references in prose but not in code, links or paths", async () => {
const shorthand = createForgeShorthand({
forge: "gitlab",
remoteUrl: "git@gitlab.com:g/p.git",
})!;
const source = [
"Fixed in #12 and !13, see g/sub/q#14 and https://example.com/a#15.",
"`#16` and [#17](https://example.com) and src/app/index.ts and src/a/b/c#18",
"```\n#19\n```",
].join("\n\n");
const prepared = prepareMarkdownSource(source, "/repo", {
forgeReferencePattern: shorthand.pattern,
});
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const recognized: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
recognized.push(String(node.attributes.url));
}
node.children?.forEach(collect);
}
collect(tree);
expect(recognized).toEqual(["#12", "!13", "g/sub/q#14", "src/app/index.ts", "src/a/b/c#18"]);
expect(recognized.map((url) => shorthand.resolve(url))).toEqual([
"https://gitlab.com/g/p/-/issues/12",
"https://gitlab.com/g/p/-/merge_requests/13",
"https://gitlab.com/g/sub/q/-/issues/14",
null,
"https://gitlab.com/src/a/b/c/-/issues/18",
]);
// A rejected native regex silently falls back to no recognition at all.
expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({
isDisabled: false,
isDefault: false,
});
});
describe("bare URL sentence punctuation", () => {
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
"leaves %s outside the link and preserves the rendered sentence",
@@ -7,6 +7,8 @@ import {
} from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event";
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { openExternalUrl } from "@/utils/open-external-url";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
@@ -89,6 +91,7 @@ const MarkdownPart = memo(function MarkdownPart({
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const forgeShorthand = useForgeShorthand();
const client =
imageContext.client ??
(environment.client?.readFile
@@ -102,7 +105,7 @@ const MarkdownPart = memo(function MarkdownPart({
...imageContext,
client,
},
commandHints?.names,
{ commandNames: commandHints?.names, forgeReferencePattern: forgeShorthand?.pattern },
);
const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
@@ -111,6 +114,11 @@ const MarkdownPart = memo(function MarkdownPart({
commandHints.insert(command);
return;
}
const forgeUrl = sourceType === "text" ? forgeShorthand?.resolve(url) : null;
if (forgeUrl) {
void openExternalUrl(forgeUrl);
return;
}
const source = linkSource(url, sourceType);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
@@ -7,6 +7,7 @@ import type {
} from "react-native-enriched-markdown";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useFileLink } from "@/assistant-file-links";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
@@ -31,7 +32,13 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
const forgeShorthand = useForgeShorthand();
// A forge reference like `#12` carries its web URL so hover, copy and new-tab opens work.
const forgeUrl = link.sourceType === "text" ? forgeShorthand?.resolve(link.url) : null;
const { tooltipPath, onHoverIn } = useFileLink(
forgeUrl ? markdownLinkSource(forgeUrl) : markdownLinkSource(link.url, link.sourceType),
);
const tooltip = forgeUrl ?? tooltipPath;
const [hovered, setHovered] = useState(false);
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
@@ -46,6 +53,7 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const anchor = (
<a
{...anchorProps}
href={forgeUrl ?? anchorProps.href}
style={anchorStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -58,10 +66,10 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
<TooltipTrigger asChild onHoverIn={onHoverIn}>
<InlineLinkTrigger>{anchor}</InlineLinkTrigger>
</TooltipTrigger>
{tooltipPath ? (
{tooltip ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}>
{tooltipPath}
{tooltip}
</Text>
</TooltipContent>
) : null}
+26 -6
View File
@@ -24,10 +24,12 @@ const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
// Bare prose paths need a known extension, a line suffix, or at least two separators.
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
function createFilePattern(path: string): RegExp {
function createTextPattern(path: string, forgeReferencePattern?: string): RegExp {
// Forge references come before paths so `group/project#12` is not read as a path.
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp(
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}
@@ -38,8 +40,20 @@ const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const FILE_PATTERN = createFilePattern(PROSE_PATH);
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
const textPatternsByForgeReference = new Map<string, RegExp>();
function textPattern(forgeReferencePattern?: string): RegExp {
if (!forgeReferencePattern) return TEXT_PATTERN;
let pattern = textPatternsByForgeReference.get(forgeReferencePattern);
if (!pattern) {
pattern = createTextPattern(PROSE_PATH, forgeReferencePattern);
textPatternsByForgeReference.set(forgeReferencePattern, pattern);
}
return pattern;
}
function linkMetadata(inlineCodePaths: ReadonlySet<string>, forgeReferencePattern?: string) {
// The engine accepts regexes on both platforms. Classify complete code spans
// first, then pass literal matches rather than maintaining a second path grammar.
const alternatives = [...inlineCodePaths].map((path) =>
@@ -47,7 +61,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
);
return {
linkRecognition: {
text: FILE_PATTERN,
text: textPattern(forgeReferencePattern),
inlineCode: new RegExp(
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
),
@@ -120,7 +134,12 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
export function prepareMarkdownSource(
source: string,
workspaceRoot?: string,
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
options: {
supportsSvg?: boolean;
commandNames?: ReadonlySet<string>;
/** Regex source for forge references such as `#12`; see `@/git/forge-shorthand`. */
forgeReferencePattern?: string;
} = {},
) {
source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = [];
@@ -132,6 +151,7 @@ export function prepareMarkdownSource(
}));
const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
options.forgeReferencePattern,
);
const edits: { start: number; end: number; text: string }[] = [];
if (
@@ -1235,7 +1235,7 @@ function ProviderErrorEmptyState({
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
<Text style={styles.emptyStateText}>{message}</Text>
{onRetryProvider ? (
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
<Button variant="default" onPress={handleRetry} disabled={isRetryingProvider}>
{isRetryingProvider ? t("modelSelector.retrying") : t("modelSelector.retry")}
</Button>
) : null}
@@ -9,9 +9,8 @@ import type { ProjectIconSource } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ProjectIconView } from "@/components/project-icon-view";
import { Button } from "@/components/ui/button";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import type { ControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useFilePicker } from "@/hooks/use-file-picker";
import {
@@ -50,7 +49,7 @@ export function ProjectEditSheet({
const queryClient = useQueryClient();
const toast = useToast();
const { pickFiles } = useFilePicker();
const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const size: ControlSize = "md";
const [form] = useState(() => openProjectEditForm(snapshot));
const state = useSyncExternalStore(form.subscribe, form.getState, form.getState);
@@ -96,7 +95,6 @@ export function ProjectEditSheet({
<View style={styles.footer}>
<Button
variant="secondary"
size="md"
style={styles.footerButton}
onPress={handleClose}
disabled={isSaving}
@@ -105,7 +103,6 @@ export function ProjectEditSheet({
</Button>
<Button
variant="default"
size="md"
style={styles.footerButton}
onPress={handleSubmit}
disabled={!state.canSubmit}
@@ -89,7 +89,7 @@ function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps)
</View>
</View>
<Button
size="xs"
size="sm"
variant="outline"
disabled={installing}
loading={installing}
@@ -222,10 +222,10 @@ function AddCustomModelSubSheet({
/>
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
<View style={sheetStyles.formActions}>
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
<Button variant="secondary" onPress={onClose} disabled={saving}>
{t("common.actions.cancel")}
</Button>
<Button variant="default" size="sm" onPress={handleAdd} disabled={!canAdd || saving}>
<Button variant="default" onPress={handleAdd} disabled={!canAdd || saving}>
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
</Button>
</View>
@@ -436,18 +436,11 @@ function renderProviderSheetFooter({
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
) : null}
<View style={actionsStyle}>
<Button
variant="secondary"
size="sm"
leftIcon={Plus}
onPress={onOpenAddSheet}
style={buttonStyle}
>
<Button variant="secondary" leftIcon={Plus} onPress={onOpenAddSheet} style={buttonStyle}>
{t("settings.providers.models.addModel")}
</Button>
<Button
variant="secondary"
size="sm"
leftIcon={FileText}
onPress={onOpenDiagSheet}
style={buttonStyle}
@@ -456,7 +449,6 @@ function renderProviderSheetFooter({
</Button>
<Button
variant="default"
size="sm"
leftIcon={modelsRefreshing ? undefined : RotateCw}
onPress={onRefreshModels}
disabled={modelsRefreshing}
@@ -520,7 +512,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
<View style={sheetStyles.emptyState}>
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
<Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}>
<Button variant="default" onPress={onRefresh} disabled={modelsRefreshing}>
{modelsRefreshing
? t("settings.providers.models.retrying")
: t("settings.providers.models.retry")}
@@ -145,7 +145,6 @@ export function AdaptiveRenameModal({
<View style={styles.actions}>
<Button
variant="secondary"
size="sm"
style={styles.actionButton}
onPress={handleCancel}
disabled={isPending}
@@ -155,7 +154,6 @@ export function AdaptiveRenameModal({
</Button>
<Button
variant="default"
size="sm"
style={styles.actionButton}
onPress={handleSubmitVoid}
disabled={submitDisabled}
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useReducer, useState, type ReactNode } from "reac
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import type { ControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
import {
@@ -20,7 +20,7 @@ export interface CadenceEditorProps {
value: ScheduleCadence;
onChange: (next: ScheduleCadence) => void;
error?: string;
size?: FieldControlSize;
size?: ControlSize;
}
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
@@ -19,9 +19,8 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
import { ComboboxItem } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import { createControlGeometry, type FieldControlSize } from "@/components/ui/control-geometry";
import { createControlGeometry, type ControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { Switch } from "@/components/ui/switch";
import { useProviderIcon } from "@/components/provider-icons";
@@ -239,7 +238,7 @@ function OpenScheduleFormSheet({
mode,
schedule,
}: ScheduleFormSheetProps & { onDismiss: () => void }): ReactElement {
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const controlSize: ControlSize = "md";
const { projects } = useProjects();
const hostProfiles = useHosts();
const hosts = useStoreWithEqualityFn(
@@ -460,7 +459,7 @@ interface ScheduleFormFieldsProps {
state: ScheduleFormState;
providerSnapshot: ReturnType<typeof useScheduleFormProviderSnapshot>;
agentTargetLabel: string | null;
controlSize: FieldControlSize;
controlSize: ControlSize;
cadenceError: string | null;
mutationServerId: string;
}
@@ -557,7 +556,7 @@ interface ScheduleTargetFieldsProps {
state: ScheduleFormState;
providerSnapshot: ReturnType<typeof useScheduleFormProviderSnapshot>;
agentTargetLabel: string | null;
controlSize: FieldControlSize;
controlSize: ControlSize;
mutationServerId: string;
}
@@ -819,7 +818,7 @@ function ScheduleIsolationField({
}: {
model: ScheduleFormModel;
state: ScheduleFormState;
size: FieldControlSize;
size: ControlSize;
}): ReactElement {
const options = useMemo<SelectFieldOption<"local" | "worktree">[]>(
() => [
@@ -892,7 +891,7 @@ function ScheduleAgentTargetField({
size,
}: {
label: string | null;
size: FieldControlSize;
size: ControlSize;
}): ReactElement {
const fieldStyle = useMemo(
() => [styles.readonlyField, size === "sm" ? styles.readonlyFieldSm : styles.readonlyFieldMd],
@@ -1,12 +1,19 @@
import type { ReactNode } from "react";
import { SettingsRowHeader } from "./row-header";
interface SettingsDetailRowProps {
label: string;
/** Shown beside the label, such as a badge. */
labelAccessory?: ReactNode;
/** A line under the label saying what the row leads to. */
hint?: string;
/** What the detail is set to, so the row says it without being opened. */
summary?: string;
onPress: () => void;
/** A row that cannot be opened keeps its summary and drops the chevron. */
disabled?: boolean;
/** Opens a page outside the app; an outward arrow replaces the chevron. */
external?: boolean;
testID?: string;
}
@@ -14,20 +21,6 @@ interface SettingsDetailRowProps {
* A settings row that opens a detail, such as a sheet, for one setting. The label, a one-line
* summary of the current value and a chevron pointing right. Sits inside a `SettingsCard`.
*/
export function SettingsDetailRow({
label,
summary,
onPress,
disabled,
testID,
}: SettingsDetailRowProps) {
return (
<SettingsRowHeader
label={label}
summary={summary}
onPress={onPress}
disabled={disabled}
testID={testID}
/>
);
export function SettingsDetailRow(props: SettingsDetailRowProps) {
return <SettingsRowHeader {...props} />;
}
@@ -1,6 +1,7 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ExternalLink, type ExternalLinkProps } from "@/components/ui/external-link";
import { SettingsInfoTip } from "./settings-info-tip";
import { settingsStyles } from "@/styles/settings";
@@ -11,6 +12,12 @@ interface SettingsSectionProps {
* paragraph between the header and the card is wrong (docs/design.md §7).
*/
info?: ReactNode;
/**
* A page outside the app, such as the docs, shown at the header's end as "Label ↗". Its glyph
* sits as far inside the card's edge as the title does on the other side.
*/
link?: Pick<ExternalLinkProps, "href" | "label" | "accessibilityLabel" | "tooltip" | "testID">;
/** An action at the header's end, such as Refresh or Add. A link to a page is `link`. */
trailing?: ReactNode;
testID?: string;
style?: StyleProp<ViewStyle>;
@@ -30,6 +37,7 @@ interface SettingsSectionProps {
export function SettingsSection({
title,
info,
link,
trailing,
testID,
style,
@@ -53,6 +61,11 @@ export function SettingsSection({
/>
) : null}
</View>
{link ? (
<View style={styles.link}>
<ExternalLink {...link} />
</View>
) : null}
{trailing}
</View>
<View style={styles.content}>{children}</View>
@@ -69,6 +82,10 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
// Mirrors the header's marginLeft, so the link's glyph and the title's sit on matching rails.
link: {
marginRight: theme.spacing[1],
},
titleRow: {
flexDirection: "row",
alignItems: "center",
@@ -1,8 +1,8 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import type { ContainedControlSize } from "@/components/ui/control-size";
import { CONTROL_HEIGHTS, type ControlSize } from "@/components/ui/control-geometry";
import { settingsStyles } from "@/styles/settings";
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
@@ -19,7 +19,7 @@ export interface SettingsRowLayout {
title: StyleProp<TextStyle>;
hint: StyleProp<TextStyle>;
/** The size of the controls in the row's trailing slot. */
controlSize: ContainedControlSize;
controlSize: ControlSize;
}
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
@@ -32,24 +32,24 @@ export function useSettingsRowLayout(): SettingsRowLayout {
row: [settingsStyles.row, styles.nestedRow],
title: [settingsStyles.rowTitle, styles.nestedText],
hint: [settingsStyles.rowHint, styles.nestedText],
controlSize: "xs",
controlSize: "sm",
}
: {
row: settingsStyles.row,
title: settingsStyles.rowTitle,
hint: settingsStyles.rowHint,
controlSize: "sm",
controlSize: "md",
},
[nested],
);
}
const styles = StyleSheet.create((theme) => ({
// Every nested row is as tall as one holding an `xs` control, so rows with and without a
// Every nested row is as tall as one holding an `sm` control, so rows with and without a
// trailing button line up.
nestedRow: {
paddingVertical: theme.spacing[2],
minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2,
minHeight: CONTROL_HEIGHTS.sm + theme.spacing[2] * 2,
},
nestedText: {
fontSize: theme.fontSize.sm,
@@ -1,7 +1,8 @@
import { ChevronRight } from "lucide-react-native";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, type ReactNode } from "react";
import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLinkLabel } from "@/components/ui/external-link";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { PressHighlight } from "@/components/ui/press-highlight";
import { settingsStyles } from "@/styles/settings";
@@ -15,12 +16,18 @@ type HoverState = PressableStateCallbackType & { hovered?: boolean };
export interface SettingsRowHeaderProps {
label: string;
/** Shown beside the label, such as a badge. */
labelAccessory?: ReactNode;
/** A line under the label saying what the row leads to. */
hint?: string;
/** A short status shown before the chevron. */
summary?: string;
onPress: () => void;
disabled?: boolean;
/** Turns the chevron down, for a row whose children are revealed in place. */
expanded?: boolean;
/** The row leaves the app, such as to a docs page: the summary and an outward arrow are drawn as an external link in place of the chevron. */
external?: boolean;
accessibilityState?: AccessibilityState;
/** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */
webAccessibilityProps?: Record<string, unknown> | null;
@@ -34,10 +41,13 @@ export interface SettingsRowHeaderProps {
*/
export function SettingsRowHeader({
label,
labelAccessory,
hint,
summary,
onPress,
disabled,
expanded = false,
external = false,
accessibilityState,
webAccessibilityProps,
testID,
@@ -58,7 +68,7 @@ export function SettingsRowHeader({
return (
<PressHighlight
{...webAccessibilityProps}
accessibilityRole="button"
accessibilityRole={external ? "link" : "button"}
accessibilityLabel={summary ? `${label}, ${summary}` : label}
accessibilityState={accessibilityState}
disabled={disabled}
@@ -70,20 +80,35 @@ export function SettingsRowHeader({
{({ hovered }: HoverState) => (
<>
<View style={settingsStyles.rowContent}>
<Text style={layout.title}>{label}</Text>
{labelAccessory ? (
<View style={styles.labelRow}>
<Text style={layout.title}>{label}</Text>
{labelAccessory}
</View>
) : (
<Text style={layout.title}>{label}</Text>
)}
{hint ? <Text style={layout.hint}>{hint}</Text> : null}
</View>
{summary ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : null}
{disabled ? null : (
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
{external ? (
// Drawn the same as a section header's docs link, so the two read as one thing.
<ExternalLinkLabel hovered={hovered}>{summary}</ExternalLinkLabel>
) : (
<>
{summary ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : null}
{disabled ? null : (
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
)}
</>
)}
</>
)}
@@ -92,6 +117,7 @@ export function SettingsRowHeader({
}
const styles = StyleSheet.create((theme) => ({
labelRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
summary: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { useMemo } from "react";
import { Animated, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { projectIconRadius } from "@/components/project-icon-view";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
const PROJECT_ICON_SIZE = 16;
@@ -11,17 +12,6 @@ const ROW_KEYS_BY_SECTION: readonly (readonly string[])[] = SECTION_OPACITIES.ma
[0, 1, 2].map((r) => `skeleton-row-${sIdx}-${r}`),
);
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
function SkeletonSection({
pulse,
sectionOpacity,
@@ -57,27 +47,7 @@ function SkeletonSection({
}
export function SidebarAgentListSkeleton() {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulse, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
const pulse = useSkeletonPulse();
return (
<View style={styles.container}>
@@ -56,7 +56,7 @@ export function SidebarFilterEmptyState() {
>
<Button
variant="secondary"
size="xs"
size="sm"
style={styles.action}
textStyle={styles.actionText}
onPress={clearFilters}
@@ -84,7 +84,7 @@ export function SidebarProjectEmptyState({
>
<Button
variant="secondary"
size="xs"
size="sm"
leftIcon={Plus}
style={styles.action}
textStyle={styles.actionText}
@@ -94,7 +94,7 @@ export function SidebarProjectEmptyState({
</Button>
<Button
variant="outline"
size="xs"
size="sm"
leftIcon={Import}
style={styles.action}
textStyle={styles.actionText}
+8 -2
View File
@@ -1,3 +1,7 @@
import {
FloatingHeaderOverlay,
useFloatingHeaderOverlayClearance,
} from "@/components/floating-header";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import {
@@ -190,9 +194,11 @@ export function ToastViewport({
return clearTimer;
}, [clearTimer, durationMs, scheduleDismiss, toastId]);
const panelClearance = useFloatingHeaderOverlayClearance();
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
const topOffset =
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
const content = (
<View
@@ -220,7 +226,7 @@ export function ToastViewport({
return createPortal(content, getOverlayRoot());
}
return content;
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
}
function ToastCard({
+4 -4
View File
@@ -3,14 +3,14 @@ import { type ReactNode, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
type ButtonControlSize,
buttonIconSize,
ALERT_ICON_SIZE,
type AlertSize,
createControlGeometry,
} from "@/components/ui/control-geometry";
import { hexColorWithAlpha } from "@/utils/color";
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
export type AlertSize = ButtonControlSize;
export type { AlertSize };
export interface AlertProps {
title?: string;
@@ -46,7 +46,7 @@ export function Alert({
const sized = resolveSizeStyles(size);
const containerStyle = [styles.container, sized.container, { borderColor }];
const titleStyle = [styles.title, sized.text, accentColor ? { color: accentColor } : null];
const iconSize = buttonIconSize[size];
const iconSize = ALERT_ICON_SIZE[size];
const resolvedIcon = useMemo(() => {
if (variant === "default") return null;
+16 -27
View File
@@ -20,14 +20,13 @@ import type {
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
buttonIconSize,
controlIconSize,
createControlGeometry,
type ButtonControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = ButtonControlSize;
type LeftIcon =
| ReactElement
@@ -102,15 +101,11 @@ const styles = StyleSheet.create((theme) => {
md: {
...geometry.buttonMd,
},
xs: {
...geometry.buttonXs,
},
sm: {
...geometry.buttonSm,
},
lg: {
...geometry.buttonLg,
},
iconOnlySm: geometry.buttonIconOnlySm,
iconOnlyMd: geometry.buttonIconOnlyMd,
default: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
@@ -139,11 +134,11 @@ const styles = StyleSheet.create((theme) => {
},
text: {
color: theme.colors.foreground,
...geometry.buttonText,
fontWeight: theme.fontWeight.normal,
...geometry.buttonTextMd,
fontWeight: theme.fontWeight.medium,
},
textXs: {
...geometry.buttonTextXs,
textSm: {
...geometry.buttonTextSm,
},
textDefault: {
color: theme.colors.accentForeground,
@@ -178,7 +173,7 @@ export function Button({
Omit<PressableProps, "style"> & {
ref?: React.Ref<View>;
variant?: ButtonVariant;
size?: ButtonSize;
size?: ControlSize;
leftIcon?: LeftIcon;
trailing?: ReactNode;
style?: StyleProp<ViewStyle>;
@@ -205,16 +200,9 @@ export function Button({
variantStyle = styles.destructive;
}
let sizeStyle: ViewStyle;
if (size === "xs") {
sizeStyle = styles.xs;
} else if (size === "sm") {
sizeStyle = styles.sm;
} else if (size === "lg") {
sizeStyle = styles.lg;
} else {
sizeStyle = styles.md;
}
const isIconOnly = children == null && leftIcon != null;
const sizeStyle = size === "sm" ? styles.sm : styles.md;
const iconOnlyStyle = size === "sm" ? styles.iconOnlySm : styles.iconOnlyMd;
const isGhostHovered = hovered && variant === "ghost";
const handleHoverIn = useCallback(() => setHovered(true), []);
@@ -224,18 +212,19 @@ export function Button({
({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> => [
styles.base,
sizeStyle,
isIconOnly ? iconOnlyStyle : null,
variantStyle,
pressed ? styles.pressed : null,
isDisabled ? styles.disabled : null,
style,
],
[sizeStyle, variantStyle, isDisabled, style],
[sizeStyle, isIconOnly, iconOnlyStyle, variantStyle, isDisabled, style],
);
const resolvedTextStyle = useMemo(
() => [
styles.text,
size === "xs" ? styles.textXs : null,
size === "sm" ? styles.textSm : null,
variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
@@ -276,7 +265,7 @@ export function Button({
<ThemedButtonIcon
loading={loading}
leftIcon={leftIcon}
iconSize={buttonIconSize[size]}
iconSize={controlIconSize[size]}
uniProps={resolveIconMapping()}
/>
{children != null ? (
@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
buttonControlHeight,
buttonIconSize,
ALERT_ICON_SIZE,
CONTROL_HEIGHTS,
controlHeightForLayout,
createControlGeometry,
switchGeometryForLayout,
getControlInteractionPhase,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -26,6 +28,7 @@ const theme = {
xs: 10,
sm: 12,
base: 14,
lg: 16,
},
opacity: {
50: 0.5,
@@ -36,8 +39,10 @@ const theme = {
1: 4,
2: 8,
3: 12,
3.5: 14,
4: 16,
6: 24,
7: 28,
8: 32,
},
} as unknown as Theme;
@@ -81,95 +86,104 @@ describe("control geometry", () => {
it("keeps field text sizing tied to control size", () => {
const geometry = createControlGeometry(theme);
expect(geometry.fieldTextXs.fontSize).toBe(12);
expect(geometry.fieldTextXs.lineHeight).toBe(17);
expect(geometry.fieldTextSm.fontSize).toBe(14);
expect(geometry.fieldTextSm.lineHeight).toBe(20);
expect(geometry.fieldTextSm.fontSize).toBe(12);
expect(geometry.fieldTextSm.lineHeight).toBe(17);
expect(geometry.fieldTextMd.fontSize).toBe(14);
expect(geometry.fieldTextMd.lineHeight).toBe(20);
expect(geometry.formTextInputSm.fontSize).toBe(14);
expect(geometry.formTextInputSm.lineHeight).toBe(20);
expect(geometry.formTextInputSm.fontSize).toBe(12);
expect(geometry.formTextInputMd.fontSize).toBe(14);
expect(geometry.formTextInputMd.lineHeight).toBe(20);
});
it("derives field padding from content and border without changing the control height", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlXs.minHeight).toBe(28);
expect(
geometry.fieldTextXs.lineHeight +
geometry.fieldControlXs.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlXs.minHeight);
expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
expect(geometry.fieldControlSm.minHeight).toBe(28);
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight);
expect(geometry.fieldControlMd.minHeight).toBe(44);
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlMd.minHeight);
expect(geometry.formTextInputSm.paddingVertical).toBe(5);
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
// The default keeps a 44 touch target in the compact layout and is 32 from the md
// breakpoint up.
expect(geometry.fieldControlMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.fieldControlMd.paddingVertical).toEqual({ xs: 11, md: 5 });
for (const layout of ["xs", "md"] as const) {
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical[layout] * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlMd.minHeight[layout]);
}
});
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => {
const geometry = createControlGeometry(theme);
expect(geometry.segmentedContainerXs.padding).toBe(0);
expect(geometry.segmentedContainerSm.padding).toBe(0);
expect(geometry.segmentedContainerMd.padding).toBe(0);
expect(geometry.segmentedSegmentXs.borderRadius).toBe(geometry.buttonXs.borderRadius);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(geometry.buttonSm.borderRadius);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(geometry.buttonMd.borderRadius);
expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight);
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight);
expect(geometry.segmentedSegmentXs.minHeight).toBe(24);
expect(geometry.segmentedSegmentSm.minHeight).toBe(28);
expect(geometry.segmentedSegmentMd.minHeight).toBe(38);
expect(geometry.segmentedContainerMd.minHeight).toEqual(geometry.buttonMd.minHeight);
expect(geometry.segmentedSegmentSm.minHeight).toBe(24);
expect(geometry.segmentedSegmentMd.minHeight).toEqual({ xs: 40, md: 28 });
});
it("keeps one size contract across buttons and segmented controls", () => {
it("keeps one size contract across buttons, fields, and segmented controls", () => {
const geometry = createControlGeometry(theme);
// xs is a genuinely smaller tier, not sm with a different font.
expect(geometry.buttonXs.minHeight).toBe(28);
expect(geometry.buttonSm.minHeight).toBe(32);
expect(geometry.buttonMd.minHeight).toBe(44);
expect(geometry.buttonXs.minHeight).toBe(buttonControlHeight.xs);
expect(geometry.buttonSm.minHeight).toBe(buttonControlHeight.sm);
expect(geometry.buttonMd.minHeight).toBe(buttonControlHeight.md);
expect(geometry.buttonSm.minHeight).toBe(28);
expect(geometry.buttonMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.buttonSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.buttonMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(geometry.fieldControlSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.fieldControlMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(controlHeightForLayout("md", true)).toBe(44);
expect(controlHeightForLayout("md", false)).toBe(32);
expect(controlHeightForLayout("sm", true)).toBe(28);
// Same size name means the same label size on every control kind.
expect(geometry.segmentedLabelXs.fontSize).toBe(12);
expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize);
expect(geometry.segmentedLabelSm.fontSize).toBe(14);
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize);
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize);
expect(geometry.segmentedLabelSm.fontSize).toBe(12);
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonTextSm.fontSize);
expect(geometry.segmentedLabelMd.fontSize).toBe(14);
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonTextMd.fontSize);
// Segments sit inside a track, so they run one padding step tighter than a
// standalone button of the same size — the gap between segments reads as the padding.
expect(geometry.segmentedSegmentXs.paddingHorizontal).toBeLessThan(
geometry.buttonXs.paddingHorizontal,
);
// Segments sit inside a track, so they run tighter than a standalone button of the same
// size — the gap between segments reads as the padding.
expect(geometry.segmentedSegmentSm.paddingHorizontal).toBeLessThan(
geometry.buttonSm.paddingHorizontal,
);
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBeLessThan(
geometry.buttonMd.paddingHorizontal,
);
for (const layout of ["xs", "md"] as const) {
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBeLessThan(
geometry.buttonMd.paddingHorizontal[layout],
);
}
});
it("sizes switches with the same two control sizes", () => {
expect(switchGeometryForLayout("md", false)).toEqual({
trackWidth: 34,
trackHeight: 20,
thumbSize: 16,
thumbTravel: 14,
reservedHeight: 32,
});
expect(switchGeometryForLayout("md", true)).toEqual({
trackWidth: 44,
trackHeight: 26,
thumbSize: 22,
thumbTravel: 18,
reservedHeight: 44,
});
// Tight chrome keeps the pointer-sized switch on every layout.
for (const isCompact of [true, false]) {
expect(switchGeometryForLayout("sm", isCompact)).toMatchObject({
trackWidth: 34,
reservedHeight: 28,
});
}
});
it("gives alert title and description one font size and a tight gap per size", () => {
@@ -188,7 +202,7 @@ describe("control geometry", () => {
const { alert } = createControlGeometry(theme);
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]);
expect(alert[size].iconSlot.width).toBe(ALERT_ICON_SIZE[size]);
expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap);
}
});
+106 -125
View File
@@ -1,9 +1,13 @@
import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "xs" | "sm" | "md";
export type SegmentedControlSize = "xs" | "sm" | "md";
/**
* Controls come in two sizes. `md` is the default and the right size almost everywhere;
* `sm` is for tight chrome such as toolbars and nested rows.
*/
export type ControlSize = "sm" | "md";
/** Alerts are containers, not controls, so they keep their own density scale. */
export type AlertSize = "xs" | "sm" | "md" | "lg";
export type ControlInteractionPhase = "rest" | "hover" | "active";
export interface ControlInteractionState {
@@ -22,58 +26,79 @@ export interface ControlInteractionStyleMap {
controlDisabled?: StyleProp<ViewStyle>;
}
const TIGHT_CONTROL_HEIGHT = 28;
const COMPACT_CONTROL_HEIGHT = 32;
const FIELD_CONTROL_HEIGHT = 44;
const SMALL_CONTROL_HEIGHT = 28;
// The default control keeps a full touch target in the compact layout and tightens where a
// pointer drives it.
const DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT = 44;
const DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT = 32;
export const HEADER_CONTROL_HEIGHT = 26;
const SEGMENTED_TIGHT_INSET = 2;
const SEGMENTED_COMPACT_INSET = 2;
const SEGMENTED_FIELD_INSET = 3;
const SWITCH_TRACK_WIDTH = 34;
const SWITCH_TRACK_HEIGHT = 20;
const SWITCH_THUMB_SIZE = 16;
const SEGMENTED_INSET = 2;
const CONTROL_FOCUS_RING_WIDTH = 2;
const CONTROL_FOCUS_RING_OFFSET = 1;
const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
// signature, which interfaces never satisfy.
export type LayoutValue = Record<"xs" | "md", number>;
function byLayout(compact: number, wide: number): LayoutValue {
return { xs: compact, md: wide };
}
function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
return byLayout(map(value.xs), map(value.md));
}
/**
* The three control heights every button, field, and segmented control is built from.
* Exported so a row that hosts one of those controls can size itself from the same
* numbers instead of guessing a height the control then outgrows.
* The height of every button, field, and segmented control of a size. Exported so a row that
* hosts one of those controls can size itself from the same numbers instead of guessing a
* height the control then outgrows. `md` depends on the layout, so it only works inside a
* Unistyles stylesheet.
*/
export const CONTROL_HEIGHTS = {
tight: TIGHT_CONTROL_HEIGHT,
compact: COMPACT_CONTROL_HEIGHT,
field: FIELD_CONTROL_HEIGHT,
sm: SMALL_CONTROL_HEIGHT,
md: byLayout(DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT, DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT),
} satisfies Record<ControlSize, number | LayoutValue>;
/** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */
export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number {
const height = CONTROL_HEIGHTS[size];
if (typeof height === "number") return height;
return isCompact ? height.xs : height.md;
}
export const controlIconSize: Record<ControlSize, number> = {
sm: ICON_SIZE.xs,
md: ICON_SIZE.sm,
};
export const buttonControlHeight: Record<ButtonControlSize, number> = {
xs: CONTROL_HEIGHTS.tight,
sm: CONTROL_HEIGHTS.compact,
md: CONTROL_HEIGHTS.field,
lg: CONTROL_HEIGHTS.field,
};
export interface SwitchGeometry {
trackWidth: number;
trackHeight: number;
thumbSize: number;
/** How far the thumb slides between off and on. */
thumbTravel: number;
/** The control height the switch reserves, so its row lines up with one holding a button. */
reservedHeight: number;
}
export const buttonIconSize: Record<ButtonControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
lg: ICON_SIZE.lg,
};
function switchSize(trackWidth: number, trackHeight: number, thumbSize: number) {
const inset = (trackHeight - thumbSize) / 2;
return { trackWidth, trackHeight, thumbSize, thumbTravel: trackWidth - thumbSize - inset * 2 };
}
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
};
const SWITCH_POINTER = switchSize(34, 20, 16);
const SWITCH_TOUCH = switchSize(44, 26, 22);
export const switchGeometry = {
trackWidth: SWITCH_TRACK_WIDTH,
trackHeight: SWITCH_TRACK_HEIGHT,
thumbSize: SWITCH_THUMB_SIZE,
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
};
/**
* A switch follows the same two sizes as every other control. Its thumb travel feeds an
* animation, so the size is resolved to plain numbers here instead of a stylesheet breakpoint.
*/
export function switchGeometryForLayout(size: ControlSize, isCompact: boolean): SwitchGeometry {
const track = size === "md" && isCompact ? SWITCH_TOUCH : SWITCH_POINTER;
return { ...track, reservedHeight: controlHeightForLayout(size, isCompact) };
}
function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
@@ -88,14 +113,21 @@ function fieldVerticalPadding(
}
/**
* Alerts share the button's size names, text size, and icon size (`buttonIconSize`).
* Alert density steps. Each step's icon matches the text it leads.
* The icon and the first text sit in one centered lead row; everything below is indented
* by the icon slot plus its gap so it starts at the lead text's left edge. Title and
* description use one font size; only the container gap separates them.
*/
export const ALERT_ICON_SIZE: Record<AlertSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
lg: ICON_SIZE.lg,
};
function createAlertGeometry(theme: Theme) {
function alertSize(input: {
size: ButtonControlSize;
size: AlertSize;
fontSize: number;
paddingVertical: number;
paddingHorizontal: number;
@@ -103,7 +135,7 @@ function createAlertGeometry(theme: Theme) {
iconGap: number;
textGap: number;
}) {
const iconSize = buttonIconSize[input.size];
const iconSize = ALERT_ICON_SIZE[input.size];
return {
container: {
paddingVertical: input.paddingVertical,
@@ -155,7 +187,7 @@ function createAlertGeometry(theme: Theme) {
iconGap: theme.spacing[4],
textGap: theme.spacing[1],
}),
} satisfies Record<ButtonControlSize, unknown>;
} satisfies Record<AlertSize, unknown>;
}
export function getControlInteractionPhase(
@@ -188,45 +220,28 @@ export function resolveControlInteractionStyles(
export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1];
const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlXs = {
minHeight: CONTROL_HEIGHTS.tight,
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.sm,
paddingHorizontal: theme.spacing[2],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.tight,
fieldTextXsLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.compact,
CONTROL_HEIGHTS.sm,
fieldTextSmLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlMd = {
minHeight: CONTROL_HEIGHTS.field,
paddingHorizontal: theme.spacing[4],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.field,
fieldTextMdLineHeight,
controlBorderWidth,
minHeight: CONTROL_HEIGHTS.md,
paddingHorizontal: theme.spacing[3],
paddingVertical: mapLayout(CONTROL_HEIGHTS.md, (height) =>
fieldVerticalPadding(height, fieldTextMdLineHeight, controlBorderWidth),
),
borderRadius: theme.borderRadius.lg,
};
const fieldTextXs = {
fontSize: theme.fontSize.sm,
lineHeight: fieldTextXsLineHeight,
};
const fieldTextSm = {
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.sm,
lineHeight: fieldTextSmLineHeight,
};
const fieldTextMd = {
@@ -234,43 +249,28 @@ export function createControlGeometry(theme: Theme) {
lineHeight: fieldTextMdLineHeight,
};
const alert = createAlertGeometry(theme);
const switchControl = {
minHeight: CONTROL_HEIGHTS.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
} satisfies { minHeight: number; justifyContent: "center" };
return {
buttonXs: {
minHeight: buttonControlHeight.xs,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
},
buttonSm: {
minHeight: buttonControlHeight.sm,
minHeight: CONTROL_HEIGHTS.sm,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
borderRadius: theme.borderRadius.full,
},
buttonMd: {
minHeight: buttonControlHeight.md,
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius["2xl"],
minHeight: CONTROL_HEIGHTS.md,
paddingHorizontal: byLayout(theme.spacing[6], theme.spacing[4]),
borderRadius: theme.borderRadius.full,
},
buttonLg: {
minHeight: buttonControlHeight.lg,
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius["2xl"],
},
buttonText: {
fontSize: theme.fontSize.base,
},
buttonTextXs: {
// A button with only an icon is a circle of its size's height.
buttonIconOnlySm: { width: CONTROL_HEIGHTS.sm, paddingHorizontal: 0 },
buttonIconOnlyMd: { width: CONTROL_HEIGHTS.md, paddingHorizontal: 0 },
buttonTextSm: {
fontSize: theme.fontSize.sm,
},
alert,
formTextInputXs: {
...fieldControlXs,
...fieldTextXs,
buttonTextMd: {
fontSize: theme.fontSize.base,
},
alert,
formTextInputSm: {
...fieldControlSm,
...fieldTextSm,
@@ -279,14 +279,8 @@ export function createControlGeometry(theme: Theme) {
...fieldControlMd,
...fieldTextMd,
},
formTextInput: {
...fieldControlMd,
...fieldTextMd,
},
fieldControlXs,
fieldControlSm,
fieldControlMd,
fieldTextXs,
fieldTextSm,
fieldTextMd,
controlRest: {
@@ -311,39 +305,26 @@ export function createControlGeometry(theme: Theme) {
controlDisabled: {
opacity: theme.opacity[50],
},
switchControl,
segmentedContainerXs: {
minHeight: CONTROL_HEIGHTS.tight,
padding: 0,
},
segmentedContainerSm: {
minHeight: CONTROL_HEIGHTS.compact,
minHeight: CONTROL_HEIGHTS.sm,
padding: 0,
},
segmentedContainerMd: {
minHeight: CONTROL_HEIGHTS.field,
minHeight: CONTROL_HEIGHTS.md,
padding: 0,
},
segmentedSegmentXs: {
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
},
segmentedSegmentSm: {
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
minHeight: CONTROL_HEIGHTS.sm - SEGMENTED_INSET * 2,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
borderRadius: theme.borderRadius.full,
},
segmentedSegmentMd: {
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
minHeight: mapLayout(CONTROL_HEIGHTS.md, (height) => height - SEGMENTED_INSET * 2),
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius["2xl"],
},
segmentedLabelXs: {
fontSize: theme.fontSize.sm,
borderRadius: theme.borderRadius.full,
},
segmentedLabelSm: {
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.sm,
},
segmentedLabelMd: {
fontSize: theme.fontSize.base,
@@ -1,23 +1,21 @@
import { createContext, useContext, type ReactNode } from "react";
/** The control sizes a container can ask of the controls placed in it. */
export type ContainedControlSize = "xs" | "sm";
import type { ControlSize } from "@/components/ui/control-geometry";
// A row decides how big its controls are, so a button or field placed in it needs no size of
// its own. A size passed to the control still wins.
const ControlSizeContext = createContext<ContainedControlSize | null>(null);
const ControlSizeContext = createContext<ControlSize | null>(null);
export function ControlSizeProvider({
size,
children,
}: {
size: ContainedControlSize;
size: ControlSize;
children: ReactNode;
}) {
return <ControlSizeContext.Provider value={size}>{children}</ControlSizeContext.Provider>;
}
/** The size the surrounding container asks for, or null outside one. */
export function useContainedControlSize(): ContainedControlSize | null {
export function useContainedControlSize(): ControlSize | null {
return useContext(ControlSizeContext);
}
@@ -1,11 +1,11 @@
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View, type ViewStyle } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import {
buttonIconSize,
controlIconSize,
createControlGeometry,
type ButtonControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
@@ -22,7 +22,7 @@ interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"
children: string;
/** Icon or swatch before the label. */
leading?: ReactNode;
size?: ButtonControlSize;
size?: ControlSize;
}
/**
@@ -38,17 +38,8 @@ export function DropdownTrigger({
...props
}: DropdownTriggerProps): ReactElement {
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "sm";
let sizeStyle: ViewStyle;
if (size === "xs") {
sizeStyle = styles.xs;
} else if (size === "md") {
sizeStyle = styles.md;
} else if (size === "lg") {
sizeStyle = styles.lg;
} else {
sizeStyle = styles.sm;
}
const size = sizeProp ?? containedSize ?? "md";
const sizeStyle = size === "sm" ? styles.sm : styles.md;
const triggerStyle = useCallback(
({ pressed }: { pressed: boolean }) => [
styles.base,
@@ -58,7 +49,7 @@ export function DropdownTrigger({
],
[disabled, sizeStyle],
);
const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]);
const labelStyle = useMemo(() => [styles.label, size === "sm" && styles.labelSm], [size]);
return (
<DropdownMenuTrigger {...props} disabled={disabled} style={triggerStyle}>
@@ -67,7 +58,7 @@ export function DropdownTrigger({
{children}
</Text>
<View style={styles.chevron}>
<ThemedChevronDown size={buttonIconSize[size]} uniProps={chevronColorMapping} />
<ThemedChevronDown size={controlIconSize[size]} uniProps={chevronColorMapping} />
</View>
</DropdownMenuTrigger>
);
@@ -84,10 +75,8 @@ const styles = StyleSheet.create((theme) => {
borderColor: theme.colors.borderAccent,
backgroundColor: "transparent",
},
xs: geometry.buttonXs,
sm: geometry.buttonSm,
md: geometry.buttonMd,
lg: geometry.buttonLg,
pressed: {
opacity: 0.85,
},
@@ -97,9 +86,9 @@ const styles = StyleSheet.create((theme) => {
label: {
flexShrink: 1,
color: theme.colors.foreground,
...geometry.buttonText,
...geometry.buttonTextMd,
},
labelXs: geometry.buttonTextXs,
labelSm: geometry.buttonTextSm,
chevron: {
transform: [{ translateY: 1 }],
},
@@ -1,5 +1,5 @@
import { useCallback, type ReactNode } from "react";
import { Pressable, Text } from "react-native";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ArrowUpRight } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -41,10 +41,10 @@ export function ExternalLink({
accessibilityRole="link"
accessibilityLabel={accessibilityLabel ?? label}
testID={testID}
style={styles.trigger}
>
<Text style={styles.label}>{children ?? label}</Text>
<ThemedArrowUpRight size={12} uniProps={iconColor} />
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
<ExternalLinkLabel hovered={hovered}>{children ?? label}</ExternalLinkLabel>
)}
</Pressable>
);
@@ -62,7 +62,27 @@ export function ExternalLink({
);
}
/**
* What an external link looks like: muted text and an outward arrow, both turning foreground on
* hover. For a pressable that owns its own hover, such as a settings row that opens a page.
*/
export function ExternalLinkLabel({
hovered = false,
children,
}: {
hovered?: boolean;
children?: ReactNode;
}) {
return (
<View style={styles.trigger}>
{children ? <Text style={[styles.label, hovered && styles.hovered]}>{children}</Text> : null}
<ThemedArrowUpRight size={12} uniProps={hovered ? hoveredIconColor : iconColor} />
</View>
);
}
const iconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const hoveredIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
const ThemedArrowUpRight = withUnistyles(ArrowUpRight);
const styles = StyleSheet.create((theme) => ({
@@ -75,6 +95,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
hovered: {
color: theme.colors.foreground,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
+5 -14
View File
@@ -21,18 +21,15 @@ import type { EditingTextInputHandle } from "@/components/ui/text-input";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record<
FieldControlSize,
string
>;
const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record<
FieldControlSize,
const FIELD_CHROME = { sm: "chromeSm", md: "chromeMd" } as const satisfies Record<
ControlSize,
string
>;
const FIELD_TEXT = { sm: "inputSm", md: "inputMd" } as const satisfies Record<ControlSize, string>;
interface FieldProps {
label: string;
@@ -77,7 +74,7 @@ export function Field({ label, children, trailing, hint, error, testID }: FieldP
}
type FormTextInputProps = AdaptiveTextInputProps & {
size?: FieldControlSize;
size?: ControlSize;
};
type FlatFormTextInputStyle = ViewStyle & TextStyle;
@@ -274,9 +271,6 @@ const formInputStyles = StyleSheet.create((theme) => {
chrome: {
backgroundColor: theme.colors.surface2,
},
chromeXs: {
...geometry.fieldControlXs,
},
chromeSm: {
...geometry.fieldControlSm,
},
@@ -304,9 +298,6 @@ const formInputStyles = StyleSheet.create((theme) => {
outlineColor: "transparent",
outlineWidth: 0,
},
inputXs: {
...geometry.fieldTextXs,
},
inputSm: {
...geometry.fieldTextSm,
},
@@ -4,8 +4,8 @@ import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
createControlGeometry,
segmentedIconSize,
type SegmentedControlSize,
controlIconSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
import type { Theme } from "@/styles/theme";
@@ -24,7 +24,7 @@ interface SegmentedControlProps<T extends string> {
options: SegmentedControlOption<T>[];
value: T;
onValueChange: (value: T) => void;
size?: SegmentedControlSize;
size?: ControlSize;
hideLabels?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
@@ -57,14 +57,13 @@ export function SegmentedControl<T extends string>({
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const sizeStyles = {
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd },
}[size];
const containerSizeStyle = sizeStyles.container;
const segmentSizeStyle = sizeStyles.segment;
const labelSizeStyle = sizeStyles.label;
const iconSize = segmentedIconSize[size];
const iconSize = controlIconSize[size];
const containerStyle = useMemo(
() => [styles.container, containerSizeStyle, style],
@@ -173,9 +172,6 @@ const styles = StyleSheet.create((theme) => {
backgroundColor: "transparent",
gap: theme.spacing[1],
},
containerXs: {
...geometry.segmentedContainerXs,
},
containerSm: {
...geometry.segmentedContainerSm,
},
@@ -189,9 +185,6 @@ const styles = StyleSheet.create((theme) => {
flexShrink: 0,
gap: theme.spacing[1],
},
segmentXs: {
...geometry.segmentedSegmentXs,
},
segmentSm: {
...geometry.segmentedSegmentSm,
},
@@ -218,9 +211,6 @@ const styles = StyleSheet.create((theme) => {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelXs: {
...geometry.segmentedLabelXs,
},
labelSm: {
...geometry.segmentedLabelSm,
},
@@ -13,7 +13,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import { Field } from "@/components/ui/form-field";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -55,7 +55,7 @@ export interface SelectFieldProps<TValue> {
searchable?: boolean;
searchPlaceholder?: string;
title?: string;
size?: FieldControlSize;
size?: ControlSize;
getValueKey?: (value: TValue) => string;
renderOption?: (input: SelectFieldRenderOptionInput<TValue>) => ReactElement;
triggerLeading?: ReactNode;
@@ -75,7 +75,7 @@ export interface SelectFieldTriggerProps {
disabled?: boolean;
loading?: boolean;
leading?: ReactNode;
size?: FieldControlSize;
size?: ControlSize;
testID?: string;
}
@@ -0,0 +1,34 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, type StyleProp, type ViewStyle } from "react-native";
/** One looping value per skeleton, shared by its shapes so they pulse together. */
export function useSkeletonPulse(): Animated.Value {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
return pulse;
}
/** A placeholder shape; `style` sets its size, radius and color. */
export function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
@@ -86,6 +86,7 @@ vi.mock("react-native-unistyles", () => ({
) =>
(props: Record<string, unknown>) =>
React.createElement(Component, { ...props, ...mapping?.(theme) }),
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
}));
describe("Switch", () => {
+44 -21
View File
@@ -13,7 +13,9 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { createControlGeometry, switchGeometry } from "@/components/ui/control-geometry";
import { switchGeometryForLayout, type SwitchGeometry } from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
interface SwitchProps {
@@ -29,6 +31,7 @@ const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
interface SwitchTrackProps {
value: boolean;
geometry: SwitchGeometry;
trackOffColor: string;
trackOnColor: string;
thumbOffColor: string;
@@ -37,6 +40,7 @@ interface SwitchTrackProps {
function SwitchTrack({
value,
geometry,
trackOffColor,
trackOnColor,
thumbOffColor,
@@ -48,15 +52,36 @@ function SwitchTrack({
backgroundColor: interpolateColor(progress.value, [0, 1], [trackOffColor, trackOnColor]),
}));
const { thumbTravel } = geometry;
const thumbAnimatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(progress.value, [0, 1], [thumbOffColor, thumbOnColor]),
transform: [{ translateX: progress.value * switchGeometry.thumbTravel }],
transform: [{ translateX: progress.value * thumbTravel }],
}));
const trackStyle = useMemo(() => [styles.switchTrack, trackAnimatedStyle], [trackAnimatedStyle]);
const trackSize = useMemo(
() => ({
width: geometry.trackWidth,
height: geometry.trackHeight,
borderRadius: geometry.trackHeight / 2,
padding: (geometry.trackHeight - geometry.thumbSize) / 2,
}),
[geometry],
);
const thumbSize = useMemo(
() => ({
width: geometry.thumbSize,
height: geometry.thumbSize,
borderRadius: geometry.thumbSize / 2,
}),
[geometry],
);
const trackStyle = useMemo(
() => [styles.switchTrack, trackSize, trackAnimatedStyle],
[trackSize, trackAnimatedStyle],
);
const thumbStyle = useMemo(
() => [styles.switchThumb, styles.thumb, thumbAnimatedStyle],
[thumbAnimatedStyle],
() => [thumbSize, styles.thumb, thumbAnimatedStyle],
[thumbSize, thumbAnimatedStyle],
);
return (
@@ -90,10 +115,19 @@ export function Switch({
[disabled, onValueChange, value],
);
const size = useContainedControlSize() ?? "md";
const isCompact = useIsCompactFormFactor();
const geometry = useMemo(() => switchGeometryForLayout(size, isCompact), [size, isCompact]);
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
const pressableStyle = useMemo(
() => [styles.switchControl, disabled ? styles.disabled : null, style],
[disabled, style],
() => [
styles.switchControl,
{ minHeight: geometry.reservedHeight },
disabled ? styles.disabled : null,
style,
],
[disabled, geometry.reservedHeight, style],
);
return (
@@ -108,29 +142,18 @@ export function Switch({
testID={testID}
style={pressableStyle}
>
<ThemedSwitchTrack value={value} />
<ThemedSwitchTrack value={value} geometry={geometry} />
</Pressable>
);
}
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
switchControl: {
...geometry.switchControl,
},
switchTrack: {
width: switchGeometry.trackWidth,
height: switchGeometry.trackHeight,
borderRadius: switchGeometry.trackHeight / 2,
padding: (switchGeometry.trackHeight - switchGeometry.thumbSize) / 2,
justifyContent: "center",
},
switchThumb: {
width: switchGeometry.thumbSize,
height: switchGeometry.thumbSize,
borderRadius: switchGeometry.thumbSize / 2,
switchTrack: {
justifyContent: "center",
},
thumb: {
shadowColor: "rgba(0, 0, 0, 0.25)",
@@ -283,7 +283,6 @@ export function WelcomeScreen() {
<Button
variant="ghost"
size="sm"
leftIcon={Settings}
onPress={handleOpenSettings}
style={styles.settingsButton}
@@ -179,8 +179,8 @@ export function ComposerDock({
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
</ComposerViewportContent>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
{overlay}
</ComposerViewport>
);
}
@@ -214,8 +214,8 @@ export function ComposerDock({
{composer}
</View>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
{overlay}
</ComposerViewport>
);
}
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import ts from "typescript";
import { expect, it } from "vitest";
it("keeps dock overlays outside keyboard translation in every native layout", () => {
const source = ts.createSourceFile(
"dock.tsx",
readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
let surfaces = 0;
function visit(node: ts.Node) {
if (
ts.isJsxElement(node) &&
node.openingElement.tagName.getText(source) === "KeyboardTranslateView"
) {
surfaces++;
function assertStationary(child: ts.Node) {
if (ts.isJsxExpression(child))
expect(child.expression?.getText(source)).not.toBe("overlay");
ts.forEachChild(child, assertStationary);
}
ts.forEachChild(node, assertStationary);
}
ts.forEachChild(node, visit);
}
visit(source);
expect(surfaces).toBe(2);
});
@@ -87,7 +87,7 @@ export function ComposerInputPresentation({
<View style={styles.exitRow}>
<Button
variant="ghost"
size="xs"
size="sm"
accessibilityLabel={t("composer.input.exitFullscreen")}
onPointerDown={keepEditorFocused}
onPress={onExit}
@@ -111,7 +111,7 @@ export function ComposerInputPresentation({
{showFullscreenButton && !fullscreen ? (
<Button
variant="ghost"
size="xs"
size="sm"
accessibilityLabel={t("composer.input.fullscreen")}
leftIcon={maximizeIcon}
onPointerDown={keepEditorFocused}
@@ -1635,10 +1635,10 @@ function BrowserElementAnnotationCard({
initialValue={comment}
/>
<View style={styles.annotationActions}>
<Button variant="ghost" size="sm" onPress={onCancel}>
<Button variant="ghost" onPress={onCancel}>
{t("workspace.browser.annotate.cancel")}
</Button>
<Button variant="default" size="sm" onPress={handleSubmit}>
<Button variant="default" onPress={handleSubmit}>
{t("workspace.browser.annotate.submit")}
</Button>
</View>
@@ -66,7 +66,6 @@ export function BrowserDataSection() {
</View>
<Button
variant="outline"
size="sm"
loading={isClearing}
disabled={isClearing}
onPress={handleClear}
@@ -59,7 +59,6 @@ export function DesktopNotificationsSection() {
() => (
<Button
variant="ghost"
size="sm"
leftIcon={refreshIcon}
onPress={handleRefreshPress}
disabled={isPermissionBusy}
@@ -119,7 +118,6 @@ export function DesktopNotificationsSection() {
</View>
<Button
variant="outline"
size="sm"
onPress={handleSendTestNotification}
disabled={!notificationsGranted || isPermissionBusy || isSendingTestNotification}
>
@@ -58,7 +58,7 @@ export function DesktopPermissionRow({
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
</View>
) : (
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
<Button variant="outline" onPress={onRequest} disabled={isRequesting}>
{isRequesting ? labels.requesting : labels.request}
</Button>
)}
@@ -41,7 +41,6 @@ export function DesktopPermissionsSection() {
() => (
<Button
variant="ghost"
size="sm"
leftIcon={refreshIcon}
onPress={handleRefreshPress}
disabled={isBusy}
@@ -6,11 +6,10 @@ import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
import { Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
@@ -187,12 +186,10 @@ function DaemonCliStatusModal({
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onClose}>
<Button variant="outline" onPress={onClose}>
{t("common.actions.close")}
</Button>
<Button size="sm" onPress={onCopy}>
{t("common.actions.copy")}
</Button>
<Button onPress={onCopy}>{t("common.actions.copy")}</Button>
</View>
</View>
</AdaptiveModalSheet>
@@ -284,13 +281,12 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
<Button variant="outline" leftIcon={copyIcon} onPress={handleCopyLogPath}>
{t("desktop.daemon.logs.copyPath")}
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={fileTextIcon}
onPress={handleOpenLogs}
disabled={!daemonLogs}
@@ -306,7 +302,6 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</View>
<Button
variant="outline"
size="sm"
leftIcon={activityIcon}
onPress={handleRunCliStatus}
disabled={isLoadingCliStatus}
@@ -386,15 +381,6 @@ export function LocalDaemonSection() {
void handleOpenCliStatus();
}, [handleOpenCliStatus]);
const handleOpenAdvancedSettings = useCallback(
() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL),
[],
);
const advancedSettingsIcon = useMemo(
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
const copyIcon = useMemo(
() => <Copy size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
@@ -408,21 +394,13 @@ export function LocalDaemonSection() {
[theme.iconSize.sm, theme.colors.foreground],
);
const advancedSettingsButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={advancedSettingsIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenAdvancedSettings}
accessibilityLabel={t("desktop.daemon.openAdvancedSettings")}
>
{t("desktop.daemon.advancedSettings")}
</Button>
),
[advancedSettingsIcon, handleOpenAdvancedSettings, t],
const advancedSettingsLink = useMemo(
() => ({
href: ADVANCED_DAEMON_SETTINGS_URL,
label: t("desktop.daemon.advancedSettings"),
accessibilityLabel: t("desktop.daemon.openAdvancedSettings"),
}),
[t],
);
if (!showSection) {
@@ -432,7 +410,7 @@ export function LocalDaemonSection() {
return (
<SettingsSection
title={t("desktop.daemon.title")}
trailing={advancedSettingsButton}
link={advancedSettingsLink}
testID="host-page-daemon-lifecycle-card"
>
{isLoading || isLoadingSettings ? (
@@ -471,7 +449,7 @@ export function LocalDaemonSection() {
</Text>
<Text style={settingsStyles.rowHint}>{daemonStatus.home}</Text>
</View>
<Button variant="outline" size="sm" onPress={handleStop} disabled={isStopping}>
<Button variant="outline" onPress={handleStop} disabled={isStopping}>
{isStopping
? t("desktop.daemon.lifecycle.stopping")
: t("desktop.daemon.lifecycle.stop")}
@@ -1,88 +1,72 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "expo-router/react-navigation";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Check, Terminal } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useCliInstall } from "@/desktop/hooks/use-install-status";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
export function IntegrationsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const { status, isInstalling, install, refresh } = useCliInstall();
const cli = useCliInstall();
const { available, refresh, setInstalled } = cli;
useFocusEffect(
useCallback(() => {
if (showSection) refresh();
if (available) refresh();
return undefined;
}, [refresh, showSection]),
}, [refresh, available]),
);
const docsIcon = useMemo(
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
const handleValueChange = useCallback(
(next: boolean) => {
void setInstalled(next);
},
[setInstalled],
);
const handleOpenDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
}, []);
const trailing = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={docsIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenDocs}
accessibilityLabel={t("settings.integrations.docs.openCli")}
>
{t("settings.integrations.docs.cli")}
</Button>
),
[docsIcon, handleOpenDocs, t],
const docsLink = useMemo(
() => ({
href: CLI_DOCS_URL,
label: t("settings.integrations.docs.cli"),
accessibilityLabel: t("settings.integrations.docs.openCli"),
}),
[t],
);
if (!showSection) return null;
if (!available) return null;
return (
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>
{t("settings.integrations.commandLine.title")}
</Text>
</View>
<Text style={settingsStyles.rowHint}>
{t("settings.integrations.commandLine.description")}
</Text>
</View>
{status?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View>
) : (
<Button variant="outline" size="sm" onPress={install} disabled={isInstalling}>
{isInstalling
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
)}
</View>
</View>
<SettingsSection title={t("settings.integrations.title")} link={docsLink}>
<SettingsCard>
<CommandLineRow
value={cli.status?.installed ?? false}
onValueChange={handleValueChange}
disabled={cli.status === null || cli.isWorking}
/>
</SettingsCard>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({
rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 },
mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
}));
/** The `paseo` CLI row: on means installed on this computer. */
export function CommandLineRow({
value,
onValueChange,
disabled,
}: {
value: boolean;
onValueChange: (next: boolean) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<SettingsRow
label={t("settings.integrations.commandLine.title")}
hint={t("settings.integrations.commandLine.description")}
>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
accessibilityLabel={t("settings.integrations.commandLine.accessibilityLabel")}
/>
</SettingsRow>
);
}
@@ -22,6 +22,8 @@ import {
} from "@/components/ui/text-input";
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
export const CONNECTIVITY_DOCS_URL = "https://paseo.sh/docs/connectivity";
export const TAILSCALE_DOCS_URL = `${CONNECTIVITY_DOCS_URL}#tailscale`;
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedShieldCheck = withUnistyles(ShieldCheck);
@@ -169,7 +171,7 @@ function OfferLoadError({ message, onRetry }: { message: string; onRetry: () =>
const { t } = useTranslation();
return (
<Alert size="sm" variant="error" description={message}>
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
<Button variant="outline" leftIcon={RotateCw} onPress={onRetry}>
{t("pairing.device.retry")}
</Button>
</Alert>
@@ -219,7 +221,14 @@ function RelayConsent(props: PairDeviceBodyProps) {
</View>
<View style={styles.directRow}>
<ThemedNetwork size={14} style={styles.directIcon} />
<Text style={styles.directHint}>{t("pairing.device.directConnectionHint")}</Text>
<View style={styles.directContent}>
<Text style={styles.directHint}>{t("pairing.device.directConnectionHint")}</Text>
<ExternalLink
href={TAILSCALE_DOCS_URL}
label={t("pairing.device.tailscaleDocs")}
accessibilityLabel={t("pairing.device.tailscaleDocsAccessibility")}
/>
</View>
</View>
</View>
);
@@ -254,12 +263,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
accessibilityLabel={t("pairing.link.label")}
/>
</View>
<Button
variant="outline"
size="sm"
leftIcon={props.copied ? Check : Copy}
onPress={props.onCopy}
>
<Button variant="outline" leftIcon={props.copied ? Check : Copy} onPress={props.onCopy}>
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
</Button>
</View>
@@ -338,8 +342,12 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
marginTop: 1, // optical: seats the glyph on the hint's first text line
},
directHint: {
directContent: {
flex: 1,
alignItems: "flex-start",
gap: theme.spacing[2],
},
directHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
@@ -326,6 +326,10 @@ export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export async function uninstallCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("uninstall_cli"));
}
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
export function readLegacySkillSelection(): Promise<AgentSkillSelection | null> {
return invokeDesktopCommand("read_legacy_skill_selection") as Promise<AgentSkillSelection | null>;
@@ -1,10 +1,12 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useToast } from "@/contexts/toast-context";
import {
getCliInstallStatus,
installCli,
shouldUseDesktopDaemon,
uninstallCli,
type InstallStatus,
} from "@/desktop/daemon/desktop-daemon";
import {
@@ -14,60 +16,85 @@ import {
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
interface DesktopInstallHookResult {
interface CliInstall {
/** False outside the desktop app: the CLI installs on the machine running the app. */
available: boolean;
status: InstallStatus | null;
isLoading: boolean;
isInstalling: boolean;
error: Error | null;
install: () => void;
isWorking: boolean;
/**
* Installs or removes the CLI; resolves to whether it worked. Failures are reported. Removing
* keeps a paseo command this app did not install, says so, and resolves true.
*/
setInstalled: (installed: boolean) => Promise<boolean>;
refresh: () => void;
}
export function useCliInstall(): DesktopInstallHookResult {
export function useCliInstall(): CliInstall {
const { t } = useTranslation();
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
const toast = useToast();
const available = shouldUseDesktopDaemon();
const statusQuery = useQuery<InstallStatus, Error>({
queryKey: CLI_INSTALL_STATUS_QUERY_KEY,
queryFn: getCliInstallStatus,
enabled,
enabled: available,
retry: false,
});
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
const { data: installStatus, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: t("desktop.integrations.cli.statusFailed"),
logLabel: "[Integrations] Failed to load CLI status",
});
const installMutation = useMutation<InstallStatus, Error>({
mutationFn: installCli,
onError: (error) => {
const mutation = useMutation<InstallStatus, Error, boolean>({
mutationFn: (installed) => (installed ? installCli() : uninstallCli()),
onError: (error, installed) => {
reportError({
error,
message: t("desktop.integrations.cli.installFailed"),
logLabel: "[Integrations] Failed to install CLI",
message: installed
? t("desktop.integrations.cli.installFailed")
: t("desktop.integrations.cli.uninstallFailed"),
logLabel: installed
? "[Integrations] Failed to install CLI"
: "[Integrations] Failed to uninstall CLI",
});
},
onSuccess: (nextStatus) => {
queryClient.setQueryData<InstallStatus>(CLI_INSTALL_STATUS_QUERY_KEY, nextStatus);
void queryClient.invalidateQueries({ queryKey: CLI_INSTALL_STATUS_QUERY_KEY });
},
});
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
const { mutateAsync, isPending } = mutation;
// Removing only ever deletes the CLI this app installed. A paseo command put there some other way
// stays, and that still counts as done: the user is told rather than left with a switch that
// silently won't turn off.
const setInstalled = useCallback(
(installed: boolean) =>
mutateAsync(installed).then(
(status) => {
if (status.installed === installed) return true;
if (installed) return false;
toast.show(t("desktop.integrations.cli.keptForeign"));
return true;
},
() => false,
),
[mutateAsync, t, toast],
);
const refresh = useCallback(() => {
void refetch();
}, [refetch]);
return {
available,
status: installStatus ?? null,
isLoading,
isInstalling,
error: statusError ?? installError ?? null,
install,
isWorking: isPending,
setInstalled,
refresh,
};
}
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
import {
refreshExplorerDirectories,
restoreExpandedDirectories,
showHiddenFilesAndRestoreExpandedDirectories,
reconcileRestoredExpandedPaths,
} from "./tree";
import {
DIRECTORY_LISTING_INVALIDATED,
invalidateExplorerDirectoryListings,
loadExplorerDirectory,
} from "./directory-listings";
function directory(path: string, names: string[]): ExplorerDirectory {
return {
path,
entries: names.map((name) => ({
path: `${path}/${name}`,
name,
kind: "file",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
})),
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function explorer() {
let state: AgentFileExplorerState = {
listingGeneration: 0,
directories: new Map([[".", directory(".", ["docs"])]]),
isLoading: false,
lastError: null,
pendingRequest: null,
currentPath: ".",
history: ["."],
lastVisitedPath: ".",
selectedEntryPath: null,
};
const updateState = (update: (state: AgentFileExplorerState) => AgentFileExplorerState) => {
state = update(state);
return state;
};
return {
state: () => state,
load: (path: string, result: Promise<ExplorerDirectory>) =>
loadExplorerDirectory({
path,
updateState,
listDirectory: () => result,
fallbackError: "Directory listing failed",
options: { recordHistory: false, setCurrentPath: false },
}),
refresh: (retained: readonly string[]) =>
invalidateExplorerDirectoryListings(updateState, retained),
};
}
describe("directory listings across Refresh", () => {
it("ignores an earlier reply for a collapsed folder while the fresh root is loading", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh(["."]);
const fresh = deferred<ExplorerDirectory>();
const freshLoad = model.load(".", fresh.promise);
old.resolve(directory("docs", ["OLD.md"]));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().directories.has("docs")).toBe(false);
expect(model.state().isLoading).toBe(true);
expect(model.state().pendingRequest?.path).toBe(".");
const root = directory(".", ["docs", "NEW.md"]);
fresh.resolve(root);
await expect(freshLoad).resolves.toEqual(root);
expect(model.state().directories.get(".")).toEqual(root);
expect(model.state().isLoading).toBe(false);
});
it("does not let a pre-refresh reply replace a newer listing of the same folder", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh([".", "docs"]);
const fresh = directory("docs", ["NEW.md"]);
await model.load("docs", Promise.resolve(fresh));
old.resolve(directory("docs", ["OLD.md"]));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().directories.get("docs")).toEqual(fresh);
});
it("ignores errors from requests invalidated by Refresh", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh(["."]);
const fresh = deferred<ExplorerDirectory>();
const freshLoad = model.load(".", fresh.promise);
old.reject(new Error("Old request failed"));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().lastError).toBeNull();
expect(model.state().isLoading).toBe(true);
fresh.resolve(directory(".", ["docs"]));
await freshLoad;
});
it("keeps expanded listings visible and discards collapsed listings on Refresh", async () => {
const model = explorer();
const docs = directory("docs", ["guide.md"]);
await model.load("docs", Promise.resolve(docs));
await model.load("assets", Promise.resolve(directory("assets", ["logo.png"])));
model.refresh([".", "docs"]);
expect(model.state().directories.get("docs")).toEqual(docs);
expect(model.state().directories.has("assets")).toBe(false);
expect(model.state().currentPath).toBe(".");
expect(model.state().history).toEqual(["."]);
});
});
it.each([false, true])(
"preserves saved expansion when Refresh invalidates restoration (hidden: %s)",
async (hidden) => {
const model = explorer();
const folder = hidden ? ".hidden" : "docs";
const root: ExplorerDirectory = {
path: ".",
entries: [
{
name: folder,
path: folder,
kind: "directory",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
},
],
};
const saved = new Set([".", folder]);
const pending = deferred<ExplorerDirectory>();
const input = {
rootDirectory: root,
persistedExpandedPaths: saved,
requestDirectoryListing: (path: string) => model.load(path, pending.promise),
};
const restoration = hidden
? showHiddenFilesAndRestoreExpandedDirectories({ ...input, showHiddenFiles: () => {} })
: restoreExpandedDirectories({ ...input, showHiddenFiles: false });
const fresh = directory(folder, ["NEW.md"]);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: saved,
showHiddenFiles: hidden,
invalidateDirectoryListings: model.refresh,
requestDirectoryListing: (path) =>
model.load(path, Promise.resolve(path === "." ? root : fresh)),
});
pending.resolve(directory(folder, ["OLD.md"]));
const restored = await restoration;
const expanded = reconcileRestoredExpandedPaths({
persistedExpandedPaths: saved,
currentExpandedPaths: saved,
restoredExpandedPaths: restored,
});
expect(model.state().directories.get(folder)).toEqual(fresh);
expect(expanded).toEqual([".", folder]);
},
);
@@ -0,0 +1,91 @@
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
export const DIRECTORY_LISTING_INVALIDATED = Symbol("directory-listing-invalidated");
export type DirectoryListingResult =
| ExplorerDirectory
| null
| typeof DIRECTORY_LISTING_INVALIDATED;
type UpdateExplorerState = (
update: (state: AgentFileExplorerState) => AgentFileExplorerState,
) => AgentFileExplorerState | null;
function pushHistory(history: string[], path: string): string[] {
const normalizedHistory = history.length === 0 ? ["."] : history;
return normalizedHistory.at(-1) === path ? normalizedHistory : [...normalizedHistory, path];
}
export async function loadExplorerDirectory({
path,
options,
updateState,
listDirectory,
fallbackError,
}: {
path: string;
options?: { recordHistory?: boolean; setCurrentPath?: boolean };
updateState: UpdateExplorerState;
listDirectory: () => Promise<ExplorerDirectory>;
fallbackError: string;
}): Promise<DirectoryListingResult> {
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
const initial = updateState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path, mode: "list" },
...(shouldSetCurrentPath
? {
currentPath: path,
history: shouldRecordHistory ? pushHistory(state.history, path) : state.history,
lastVisitedPath: path,
}
: {}),
}));
if (!initial) return null;
const generation = initial.listingGeneration;
try {
const directory = await listDirectory();
let accepted = false;
updateState((state) => {
if (state.listingGeneration !== generation) return state;
accepted = true;
const directories = new Map(state.directories);
directories.set(directory.path, directory);
return { ...state, directories, isLoading: false, lastError: null, pendingRequest: null };
});
return accepted ? directory : DIRECTORY_LISTING_INVALIDATED;
} catch (error) {
let invalidated = false;
updateState((state) => {
if (state.listingGeneration !== generation) {
invalidated = true;
return state;
}
return {
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : fallbackError,
pendingRequest: null,
};
});
return invalidated ? DIRECTORY_LISTING_INVALIDATED : null;
}
}
/** Refresh invalidates earlier replies as well as listings of collapsed folders. */
export function invalidateExplorerDirectoryListings(
updateState: UpdateExplorerState,
retainedPaths: readonly string[],
): void {
const retained = new Set(retainedPaths);
updateState((state) => ({
...state,
listingGeneration: state.listingGeneration + 1,
directories: new Map([...state.directories].filter(([path]) => retained.has(path))),
isLoading: false,
pendingRequest: null,
lastError: null,
}));
}
+75 -1
View File
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest";
import type { ExplorerEntry } from "@/stores/session-store";
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import {
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
flattenExplorerTree,
reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
@@ -195,3 +196,76 @@ describe("file explorer tree", () => {
await expect(restoration).resolves.toEqual([".", ".hidden"]);
});
});
describe("refreshExplorerDirectories", () => {
function makeListing(path: string, names: string[]): ExplorerDirectory {
return {
path,
entries: names.map((name) => ({
name,
path: path === "." ? name : `${path}/${name}`,
kind: "file" as const,
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
})),
};
}
function createExplorer(disk: Record<string, string[]>, cached: Record<string, string[]>) {
const directories = new Map(
Object.entries(cached).map(([path, names]) => [path, makeListing(path, names)]),
);
return {
directories,
requestDirectoryListing: async (path: string) => {
const listing = makeListing(path, disk[path] ?? []);
directories.set(path, listing);
return listing;
},
invalidateDirectoryListings: (retainedPaths: readonly string[]) => {
const retained = new Set(retainedPaths);
for (const path of directories.keys()) if (!retained.has(path)) directories.delete(path);
},
};
}
it("does not keep a stale listing for a collapsed folder", async () => {
// docs/ was expanded and collapsed earlier, then NEW.md was created in it.
const explorer = createExplorer(
{ ".": ["docs"], docs: ["README.md", "NEW.md"] },
{ ".": ["docs"], docs: ["README.md"] },
);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: new Set(["."]),
showHiddenFiles: false,
requestDirectoryListing: explorer.requestDirectoryListing,
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
});
// Expanding docs/ only fetches when it has no cached listing.
expect(explorer.directories.has("docs")).toBe(false);
expect(explorer.directories.get(".")?.entries.map((entry) => entry.name)).toEqual(["docs"]);
});
it("refreshes expanded folders and keeps their listings", async () => {
const explorer = createExplorer(
{ ".": ["src"], src: ["a.ts", "b.ts"] },
{ ".": ["src"], src: ["a.ts"] },
);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: new Set([".", "src"]),
showHiddenFiles: false,
requestDirectoryListing: explorer.requestDirectoryListing,
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
});
expect(explorer.directories.get("src")?.entries.map((entry) => entry.name)).toEqual([
"a.ts",
"b.ts",
]);
});
});
+58 -7
View File
@@ -1,5 +1,6 @@
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { DIRECTORY_LISTING_INVALIDATED, type DirectoryListingResult } from "./directory-listings";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
@@ -16,11 +17,13 @@ interface FlattenExplorerTreeInput {
showHiddenFiles: boolean;
}
type RestoredExpandedPaths = string[] | typeof DIRECTORY_LISTING_INVALIDATED;
interface RestoreExpandedDirectoriesInput {
rootDirectory: ExplorerDirectory;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
}
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
@@ -33,7 +36,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
interface ReconcileRestoredExpandedPathsInput {
persistedExpandedPaths: ReadonlySet<string>;
currentExpandedPaths: ReadonlySet<string>;
restoredExpandedPaths: string[];
restoredExpandedPaths: RestoredExpandedPaths;
}
interface SetExpandedDirectoryPathInput {
@@ -85,7 +88,7 @@ export async function restoreExpandedDirectories({
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
}: RestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
const restoredPaths = ["."];
const restoredPathSet = new Set(restoredPaths);
let parentDirectories = [rootDirectory];
@@ -110,9 +113,11 @@ export async function restoreExpandedDirectories({
const requestedDirectories = await Promise.all(
pathsToRequest.map((path) => requestDirectoryListing(path)),
);
if (requestedDirectories.includes(DIRECTORY_LISTING_INVALIDATED))
return DIRECTORY_LISTING_INVALIDATED;
parentDirectories = [];
for (const directory of requestedDirectories) {
if (!directory) {
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) {
continue;
}
restoredPaths.push(directory.path);
@@ -128,7 +133,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
showHiddenFiles();
return restoreExpandedDirectories({
rootDirectory,
@@ -143,6 +148,8 @@ export function reconcileRestoredExpandedPaths({
currentExpandedPaths,
restoredExpandedPaths,
}: ReconcileRestoredExpandedPathsInput): string[] {
if (restoredExpandedPaths === DIRECTORY_LISTING_INVALIDATED)
return Array.from(currentExpandedPaths);
const reconciledPaths = new Set(restoredExpandedPaths);
for (const path of persistedExpandedPaths) {
@@ -205,7 +212,7 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption):
/** Load and expand the path to a directory selected outside the tree. */
export async function revealExplorerDirectory(input: {
directoryPath: string;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
expandPaths: (paths: string[]) => void;
showHiddenFiles: () => void;
}): Promise<void> {
@@ -218,7 +225,51 @@ export async function revealExplorerDirectory(input: {
}
}
for (const path of paths) {
if (!(await input.requestDirectoryListing(path))) return;
const directory = await input.requestDirectoryListing(path);
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) return;
}
input.expandPaths(paths);
}
/**
* Files Refresh: re-lists the expanded directories (and the root), and drops the cached
* listing of every other directory. Expanding a directory only fetches when it has no
* cached listing, so keeping a collapsed directory's old listing would show it as it was
* before the refresh.
*/
export async function refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
showHiddenFiles,
requestDirectoryListing,
invalidateDirectoryListings,
}: {
hasWorkspaceScope: boolean;
expandedPaths: Set<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<DirectoryListingResult>;
invalidateDirectoryListings: (retainedPaths: readonly string[]) => void;
}): Promise<null> {
if (!hasWorkspaceScope) {
return null;
}
const directoryPaths = Array.from(expandedPaths).filter(
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
);
if (!directoryPaths.includes(".")) {
directoryPaths.unshift(".");
}
invalidateDirectoryListings(directoryPaths);
await Promise.all(
directoryPaths.map((path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
),
);
return null;
}
+1 -1
View File
@@ -90,7 +90,7 @@ export function FilePane({
<Text style={styles.errorText}>
{snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")}
</Text>
<Button variant="outline" size="sm" onPress={handle.retry}>
<Button variant="outline" onPress={handle.retry}>
{t("common.actions.retry")}
</Button>
</>

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