Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e546497a21 fix(workspaces): keep merged pull requests visible
* fix(workspaces): keep merged pull requests visible

A remote update before merge can leave the checkout behind the PR head. Match terminal requests by branch for display and require an exact fresh head match before automatic archive.

* fix(forges): retain pull requests targeting a fork

A fork checkout can have its own pull request as well as one targeting its parent. Compare both candidates before choosing which request to display.

* fix(forges): rank closed drafts as terminal

GitHub retains the draft flag after closing a pull request. Selection must use the request state so a closed draft does not override an open or exact-head request.
2026-11-03 13:43:02 +07: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
paseo-bot[bot] 835bc49487 fix(claude): retain final usage when a turn fails (#8519)
Non-success SDK results computed final token counts and cost but emitted only failure. Publish the existing usage update before the terminal event so clients retain completed work accounting.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 08:59:37 +00:00
Mohamed Boudra 63302287fe Fix plugin RPCs with schema transforms (#8550)
* fix(plugins): parse RPC values once at the receiver

Sending parsed values caused transforms to run again on incompatible schema outputs. Keep the wire values in schema-input form and parse only where consumed.

* fix(plugins): let omitted RPC inputs reach schema defaults

* fix(plugins): accept omitted RPC outputs for client defaults
2026-11-02 15:42:39 +07:00
paseo-bot[bot]andhadan8977 3e3a707f5a Keep moved file paths and diffs intact in commit history (#8509)
* fix(server): keep commit file paths when a file moves up a directory

* test(git): open moved commit files through their listed path

Verify commit history and diff lookup agree for files moved up a directory, with and without content edits.

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

* refactor(git): clarify compact rename handling and test cases

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

---------

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 07:58:56 +00:00
Mohamed Boudra d4ac64f335 Let plugins stream live events and binary data to the app (#8527)
* feat(plugins): stream events and bytes between plugins and the app

Plugins can declare a subscription with defineSubscription, handle it with
server.handle(contract, (input, { push, signal })), and listen from a surface
with useSubscription or client.subscribe. A top-level Uint8Array in an event,
an RPC output, or an RPC input crosses the wire as a binary frame (opcodes
0x20-0x2f) without JSON or base64; a nested one fails validation on the sender.

Each subscriber runs its own handler, owned by the session that subscribed,
so release, disconnect, plugin stop and reload need no plugin code. The
runtime buffers pushes until the subscribe response has gone out, so a client
never sees an event for a subscription it does not know yet.

Gated on server_info.features.pluginSubscriptions; binary outputs reach only
clients that advertise plugin_binary_payloads.

* fix(plugins): close subscription gaps found in review

- Losing daemon.manage now releases the session's plugin subscriptions;
  binary events bypass the outbound permission check, so streams would
  otherwise outlive the revocation.
- A subscribe that times out sends unsubscribe, since the plugin may
  still start the handler after the daemon gave up.
- The byte limit leaves room for the largest frame header and routing,
  and lives in assertPluginWireValue so events, outputs and inputs share it.
- A release right after the host accepted keeps `ready` resolved, so a
  handler that returns at once reports closed rather than an error.
- Session trace logging tolerates loggers without isLevelEnabled.

* fix(plugins): withhold binary plugin results after plugin access is revoked

A binary RPC output or event skipped the outbound permission check its
JSON form passes, so an RPC in flight when daemon.manage was revoked still
delivered its bytes. Each frame now checks the permission of the JSON
message it stands in for.

* test(plugins): cover subscriptions in a real browser instead of JSDOM

The app subscription tests mounted hooks in JSDOM, which docs/testing.md
rules out. The adapter and contract behavior is now a plain unit test,
and a Playwright spec drives the diff-watch and media-stream examples:
JSON events following file changes, streamed bytes, and an upload.

* test(plugins): add the plain subscription unit test and the browser spec

* fix(plugins): apply subscription schema transforms once

Both ends parsed each subscription value, and the sender sent its parse
output, so the receiver parsed a schema's output as fresh input. An event
schema like z.string().transform((s) => s.length) closed the stream as
invalid. The sender now validates and sends the value as given; the
receiver's parse produces what the handler or callback sees.

* fix(plugins): parse a subscription's input once in the plugin process

server.handle wrapped subscription handlers in a second input parse, so a
transforming input schema rejected its own parsed output before the
handler ran.
2026-11-02 14:10:15 +07:00
Mohamed Boudra 70cb34927a fix(app): keep question choices open until explicit navigation (#8525)
Allow description clicks to select options while users copy text. Removing automatic advance keeps that interaction reversible; links remain independent and submission stays explicit.
2026-11-02 14:08:07 +07:00
paseo-bot[bot] b280ca5355 fix(relay): keep local E2E tests independent of production upstream (#8514)
Wrangler local mode retains deployment vars, so relay readiness probes were forwarded to an unavailable upstream instead of the local Durable Object. Clear that binding only in the E2E launcher.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 13:59:19 +07:00
da8d58bfee fix(plugins): preserve agent message provenance in timeline transforms (#8492)
Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 05:04:23 +00:00
Mohamed Boudra 7c2812dde6 fix(app): add hosts without leaving the current screen (#8512)
* fix(app): add hosts without leaving the current screen

A retained Settings route replayed its addHost query after appearance remounts. Own the connection flow outside screen lifetimes and let each consumer choose its completion navigation.

* fix(app): release the project dialog before adding a host

* fix(app): size the QR scanner viewport on wide layouts

* test(app): align creation and desktop setup with in-place host flow
2026-11-02 11:07:48 +07:00
Mohamed Boudra b474beedce Homepage shows an agent building a plugin live (#8515)
* feat(website): show agents building plugins on the homepage

The plugins section now leads with the workflow instead of the API list:
you ask an agent for a panel and it appears, without a restart. It plays
as a timeline (prompt, tool calls, result) and then slides in the panel
the agent built, followed by the 400+ community plugins and their
categories. It moves above the automation section.

Mock tool calls on the homepage now match the app's collapsed timeline
rows: same labels and icons, muted text in the UI font, and Paseo MCP
tools named by action ("Create agent"). The hero pills are now nouns.

* fix(website): show the finished plugin demo when motion is reduced

The demo staged its items and slid the panel across its width even for visitors who ask for reduced motion. It now starts in its finished state for them, matching the hero mockup.
2026-11-02 10:50:12 +07:00
e2f0779379 Render Markdown tables written directly below prose (#8485)
* fix(app): render tables immediately after paragraphs

Insert a paragraph break before GFM tables adjacent to prose so MD4C
recognizes them, preserving quote/list containers and line endings.

* fix(app): preserve table headers during source adaptation

Replace the preceding line ending when inserting a table separator.
This keeps the separator before the header's source range, so entity
and local image edits cannot consume the paragraph break.

---------

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>
Co-authored-by: thsrite <thsrite@gmail.com>
Co-authored-by: Woosuk Nam <me@suk.kr>
2026-11-02 03:30:52 +00:00
paseo-bot[bot] a166a3317a test(terminal): check original processes after protocol cleanup (#8502)
Startup launches another worker after closing obsolete protocols, so a reused numeric PID does not prove the original process survived. Check the recorded birth identities and require cleanup to be confirmed.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 03:01:36 +00:00
398 changed files with 14831 additions and 2774 deletions
-8
View File
@@ -406,10 +406,7 @@
{
"files": [
"packages/app/src/app/_layout.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
@@ -428,7 +425,6 @@
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/pair-link-modal.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
@@ -543,14 +539,10 @@
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
+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
+12 -12
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.
@@ -35,8 +35,8 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
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/components/add-host-modal.tsx:19-23`, `packages/app/src/components/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`).
- **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`), `<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/components/add-host-modal.tsx`, `packages/app/src/components/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.
@@ -114,7 +114,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
@@ -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.
@@ -278,7 +278,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Form modal (label + input fields, primary + cancel) | `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` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
+6
View File
@@ -58,6 +58,12 @@ restore the same workspace and could repeat the crash.
## App-Wide Route Hops
Keep Add host in `hosts/add-host`, opened through `useAddHost()`. It is an in-place
flow; callers choose what happens after success. Settings opts into the new host’s
Overview. Do not send callers to Settings with an `addHost` query parameter: a
retained route replays that request when appearance changes remount its content.
The flow provider and sheet host stay outside appearance keys so open drafts survive.
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
+16 -13
View File
@@ -97,19 +97,15 @@ Register the adapter in `defaultForgeRegistry` with:
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately:
Current change-request lookup matches the branch and repository for both open and
terminal requests. Prefer an open request, then a terminal request at the local
HEAD, then the most recently updated terminal request. A reused branch can show
its previous request until a new one opens. Auto-archive separately requires an
observed open-to-merged transition and a fresh exact match between checkout HEAD
and the PR head, along with the existing workspace safety checks.
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Keep checkout HEAD in adapter cache and poll identities so checkout changes
refresh the facts used by archive eligibility.
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
@@ -125,7 +121,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 +209,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
+21 -3
View File
@@ -357,9 +357,27 @@ keys are stored under a `param.` prefix; plugins never see it and may use any ke
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
RPC and subscription contracts validate inputs, outputs, and events in both the app and plugin
subprocess. `useRpc` returns a typed async function. Use the host-provided `@tanstack/react-query`
for request state and caching; Paseo gives each plugin installation its own query client.
A subscription is a session-owned observation (`plugin-rpc` delivery family): the session that
subscribed owns its lifetime, so release, disconnect, plugin stop, and reload need no plugin code.
Each subscriber gets its own handler call in the subprocess; there is no fan-out or replay. The
runtime buffers pushes until the session has sent `plugin.rpc.subscribe.response`, so a client
never sees an event for an unknown subscription. The app installation's lifetime aborts every
subscription its surfaces left open, and a reload replaces the installation, which resubscribes
mounted hooks.
The byte rule (a top-level `Uint8Array` is binary, a nested one is a validation error) is checked
once per hop by `assertPluginWireValue`. Bytes ride plugin binary frames, opcodes `0x20`-`0x2f`
(`packages/protocol/src/binary-frames/plugin.ts`); nothing on the path copies or re-encodes them,
and `plugin-subscriptions.perf.e2e.test.ts` locks that. Keep it that way: never stringify a plugin
payload for a log, and the client's bootstrap buffer holds bytes only until the first observer
attaches. Binary outputs reach only clients that send `pluginBinaryPayloads`. A binary input
frame (`0x22`) becomes a synthetic `plugin.rpc.invoke.request` and runs through the normal message
path, so it is authorized like any other invoke. The author contract, including the native upload
limit, is in [stream events from the daemon](../public-docs/plugins/reference.md#subscriptions).
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
@@ -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": [
@@ -5,6 +5,7 @@ import path from "node:path";
import { test, expect } from "../support/fixtures";
import {
addProjectFlow,
chooseAddHostFromProjectFlow,
addProjectFlowBack,
addProjectFlowHost,
addProjectFlowInput,
@@ -19,6 +20,7 @@ import {
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
disconnectAllHostsAndReload,
addConnectedHostAndReload,
addConnectedHostsAndReload,
addOfflineHostAndReload,
@@ -69,6 +71,19 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
await gotoAppShell(page);
await disconnectAllHostsAndReload(page);
await openAddProjectHostSelection(page);
const originalUrl = page.url();
await chooseAddHostFromProjectFlow(page);
await expect(addProjectFlow(page)).toBeHidden();
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
});
test("method selection shows the daemon's available project sources without search", async ({
page,
}) => {
@@ -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");
});
@@ -294,10 +294,17 @@ test("creates, renames, copies, and deletes entries through the file explorer",
await entry("renamed.txt").click({ button: "right" });
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
const deleteLabelColor = await deleteAction
await expect
.poll(async () => {
const labelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
const iconColor = await deleteAction
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
return labelColor !== "" && iconColor === labelColor;
})
.toBe(true);
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
@@ -122,10 +122,14 @@ test.describe("New workspace entry points", () => {
});
const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
const originalUrl = page.url();
await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
} finally {
await seeded.cleanup();
}
+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,
}) => {
@@ -0,0 +1,79 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
/** Installs a plugin example, opens a seeded workspace, and runs `exercise` against them. */
async function withExample(
page: Page,
example: { name: string; id: string },
exercise: (workspace: SeededWorkspace) => Promise<void>,
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const copy = await copyPluginExample(example.name);
const workspace = await seedWorkspace({ repoPrefix: `${example.name}-` });
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(copy.directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await exercise(workspace);
} finally {
try {
await client.removePlugin(example.id);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await copy.cleanup();
}
}
}
test.describe("plugin subscriptions", () => {
test.describe.configure({ timeout: 180_000 });
test("a panel follows events the daemon pushes as files change", async ({ page }) => {
await withExample(page, { name: "diff-watch", id: "diff-watch" }, async (workspace) => {
await runCommand(page, "Open live changes");
await expect(page.getByText("No changes", { exact: true })).toBeVisible();
await writeFile(path.join(workspace.repoPath, "pushed.txt"), "from the test\n");
await expect(page.getByText("1 file changed", { exact: true })).toBeVisible();
await expect(page.getByText("pushed.txt", { exact: true })).toBeVisible();
});
});
test("a panel plays streamed bytes and sends bytes back", async ({ page }) => {
await withExample(page, { name: "media-stream", id: "media-stream" }, async () => {
await runCommand(page, "Open media stream");
await expect(page.getByText("live", { exact: true })).toBeVisible();
await expect(page.getByText(/^[1-9]\d* video frames/)).toBeVisible();
await page.getByRole("button", { name: "Send this frame back", exact: true }).click();
await expect(
page.getByText(/^The daemon received [1-9]\d* bytes, sha256 [0-9a-f]{12}$/),
).toBeVisible();
});
});
});
+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
@@ -108,10 +108,7 @@ test.describe("Question option text", () => {
}
});
test("selecting description text never answers, and the label still does", async ({
page,
context,
}) => {
test("selecting description text keeps the question open", async ({ page, context }) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
@@ -120,16 +117,13 @@ test.describe("Question option text", () => {
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
await expectChecked(done, false);
await description.getByText("Logs are at", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Logs");
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(done, false);
await done.getByText("Done", { exact: true }).click();
await expectChecked(done, true);
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await openQuestion(page, { index: 1, total: 2 });
await expectChecked(done, true);
@@ -138,11 +132,17 @@ test.describe("Question option text", () => {
}
});
test("double-clicking a single-select label answers once", async ({ page, context }) => {
test("double-clicking a single-select label selects once without advancing", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
@@ -154,14 +154,12 @@ test.describe("Question option text", () => {
}
});
test("multi-select description links and selection leave the option unchanged", async ({
page,
context,
}) => {
test("multi-select descriptions support links and text selection", async ({ page, context }) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
const lint = card.getByRole("checkbox", { name: "Lint" });
@@ -177,18 +175,16 @@ test.describe("Question option text", () => {
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
await expectChecked(lint, false);
await description.getByText("Rules:", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Rules");
await expectChecked(lint, false);
await expectChecked(lint, true);
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
await expectChecked(typecheck, true);
await lint.getByText("Lint", { exact: true }).click();
await expectChecked(lint, true);
await expectChecked(lint, false);
} finally {
await session.cleanup();
}
@@ -268,6 +264,7 @@ test.describe("Question prompt pagination", () => {
await expectQuestionHidden(page, SUCCESS_QUESTION);
await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await expectCurrentQuestion(page, {
index: 2,
total: TOTAL_QUESTIONS,
@@ -288,6 +285,7 @@ test.describe("Question prompt pagination", () => {
preview: 'flags.enabled("new-surface")',
});
await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await expectCurrentQuestion(page, {
index: 3,
total: TOTAL_QUESTIONS,
@@ -466,7 +464,9 @@ retryTest(
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.",
@@ -520,7 +520,9 @@ retryTest(
const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Start with the internal team.",
@@ -1,3 +1,8 @@
import {
openGlobalNewWorkspaceComposer,
fillNewWorkspaceDraft,
expectNewWorkspaceDraft,
} from "../support/helpers/new-workspace";
import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -257,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.");
@@ -273,3 +281,71 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
page,
}) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await expect(page).toHaveURL(originalUrl);
await closeAddHostAndChangeTheme(page);
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await expect(page).toHaveURL(originalUrl);
});
async function openSidebarAddHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Hosts", exact: true }).click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
}
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await page.keyboard.press("Control+Alt+t");
}
test("sidebar Add host completes without navigating", async ({ page }) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
});
test("Settings Add host opens the saved host Overview", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
await expectSettingsHeader(page, "Overview");
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
});
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
await gotoAppShell(page);
await openGlobalNewWorkspaceComposer(page);
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
const originalUrl = page.url();
await openNewWorkspaceAddHost(page);
await expect(page).toHaveURL(originalUrl);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
});
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
await page.getByTestId("host-picker-trigger").click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
}
async function connectToTestHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
}
@@ -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,
},
);
@@ -113,3 +113,7 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
})
.toBe(true);
}
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
}
@@ -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())),
);
},
};
}
+22
View File
@@ -273,3 +273,25 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
);
await page.reload();
}
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
await page.evaluate(
({ registryKey, nonceKey, disableSeedKey }) => {
const nonce = localStorage.getItem(nonceKey);
if (!nonce) throw new Error("Expected the e2e seed nonce");
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
for (const host of hosts) {
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
host.preferredConnectionId = "offline";
}
localStorage.setItem(registryKey, JSON.stringify(hosts));
localStorage.setItem(disableSeedKey, nonce);
},
{
registryKey: REGISTRY_KEY,
nonceKey: SEED_NONCE_KEY,
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
);
await page.reload();
}
@@ -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>
+123 -163
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")}
<SettingsSection title={t("settings.host.skills.sectionTitle")} link={docsLink}>
<SettingsCard testID="host-agent-skills-card">
<AgentSkillsRow
skills={skills}
value={areSkillsInstalled(skills.status)}
onValueChange={handleValueChange}
/>
{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}
/>
) : 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>
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],
);
}
if (state === "drift") {
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 (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onReconcile} disabled={isWorking}>
<>
<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>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.host.skills.actions.uninstall")}
</Button>
</View>
);
) : 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))
}
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking
? t("settings.host.skills.actions.installing")
: t("settings.host.skills.actions.install")}
</Button>
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}
<SettingsRow label={name}>
<Switch
value={checked}
onValueChange={handleValueChange}
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>
);
}
@@ -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>
@@ -69,6 +69,9 @@ function installProbe(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
}
+6 -1
View File
@@ -1,3 +1,4 @@
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
@@ -35,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";
@@ -465,6 +467,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<AddHostFlowHost />
<AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
@@ -494,6 +497,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<OnboardingHost />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
@@ -805,7 +809,6 @@ function RootStack() {
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
@@ -890,11 +893,13 @@ function RootAppTree() {
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<AddHostProvider>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</AddHostProvider>
</LucideProvider>
</View>
</GestureHandlerRootView>
+2 -3
View File
@@ -35,10 +35,9 @@ function SettingsDaemonRedirect() {
}
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
@@ -50,5 +49,5 @@ export default function SettingsSectionRoute() {
);
}
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
return <SettingsScreen view={view} />;
}
@@ -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>
);
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -92,7 +93,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path";
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
interface AddProjectFlowProps {
request: AddProjectFlowRequest;
@@ -325,6 +326,7 @@ function setPageStatus(
// The product flow is intentionally one cohesive page-stack state machine.
// eslint-disable-next-line complexity
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const addHost = useAddHost();
const hosts = useHosts();
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
@@ -616,9 +618,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
icon: Plus,
testID: "add-project-flow-add-host",
select: () => {
// The native project Modal must release its window before the root sheet opens.
onClose();
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
addHost();
},
});
}
@@ -717,6 +719,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
directoryPaths,
githubQuery.data,
host,
addHost,
onClose,
openAddedProject,
page,
@@ -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}
+4 -9
View File
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
CircleGauge,
@@ -62,7 +63,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -177,14 +178,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
router.push(buildSettingsRoute());
}, []);
const handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleAddHostMobile = useAddHost();
const handleAddHostDesktop = handleAddHostMobile;
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
@@ -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||`.",
@@ -130,6 +162,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
);
});
it("renders a table directly after a paragraph through the library parser", async () => {
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const flags = { hardSoftBreaks: true, latexMath: false };
const expected = await parseMarkdown(separated, flags);
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
});
it.each([
[
"a quote",
"> Notes\n> | Item |\n> | --- |\n> | A |",
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item",
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quoted list item",
"> - Notes\n> | Item |\n> | --- |\n> | A |",
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
],
[
"a quote inside a list item",
"- > Notes\n > | Item |\n > | --- |\n > | A |",
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
],
[
"aligned columns without outer pipes",
"Notes\nItem | Detail\n:--- | ---:\nA | one",
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
["Document", "Paragraph", "Table"],
],
[
"an entity at the start of a header without outer pipes",
"Notes\nAT&amp;T | Detail\n--- | ---\nA | one",
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"a local image at the start of a header without outer pipes",
"Notes\n![Local](assets/a.png) | Detail\n--- | ---\nA | one",
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"CRLF lines",
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
["Document", "Paragraph", "Table"],
],
[
"CR lines",
"Notes\r| Item |\r| --- |\r| A |",
"Notes\r\r| Item |\r| --- |\r| A |",
["Document", "Paragraph", "Table"],
],
[
"a quote with CRLF lines",
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CRLF lines",
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quote with CR lines",
"> Notes\r> | Item |\r> | --- |\r> | A |",
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CR lines",
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a non-interrupting ordered marker with tildes",
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
["Document", "Paragraph", "Table"],
],
[
"a non-interrupting ordered marker with backticks",
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
["Document", "Paragraph", "Table"],
],
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const flags = { hardSoftBreaks: true, latexMath: false };
function blocks(node: ASTNode): unknown {
if (node.type === "Paragraph" || node.type === "Table") return node.type;
return [node.type, ...(node.children ?? []).map(blocks)];
}
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
});
it.each([
[
"a literal backslash",
"Notes\\\n| Item |\n| --- |\n| A |",
"Notes\\\n\n| Item |\n| --- |\n| A |",
"Notes\\",
],
[
"an entity and literal backslash with LF lines",
"AT&amp;T\\\n| Item |\n| --- |\n| A |",
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CRLF lines",
"AT&amp;T\\\r\n| Item |\r\n| --- |\r\n| A |",
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CR lines",
"AT&amp;T\\\r| Item |\r| --- |\r| A |",
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
"AT&T\\",
],
])(
"preserves paragraph text before an interrupting table in %s",
async (_name, source, separated, text) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
},
);
it.each([
"```yaml\n---\nkey: value\n---\n```\n",
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
"> ```md\n> | Item |\n> | --- |\n> ```\n",
"> ```md\n> Code\n",
"- ```md\n Code\n",
" ```\n\n",
"```not`a-fence\n",
])("renders a table after a fence without changing its code: %s", async (prefix) => {
const table = "| Item |\n| --- |\n| A |";
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
});
it.each([
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item |\n| --- |\n| A |",
"| Item |\n| --- |\n| A |",
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
"```yaml\n---\nkey: value\n---\n```",
"```yaml\n---\nkey: value",
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
"````md\n```\n| Item |\n| --- |\n```\n````",
" Notes\n | Item |\n | --- |\n | A |",
"Notes\n| Item | Detail |\n| --- |\n| A |",
])("leaves separated tables and table-like text unchanged: %s", (source) => {
expect(prepareMarkdownSource(source).render()).toEqual(source);
});
it.each([
"src/components/message.tsx",
"C:\\repo\\file.ts",
@@ -232,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}
+50 -11
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,12 +151,17 @@ export function prepareMarkdownSource(
}));
const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
options.forgeReferencePattern,
);
const edits: { start: number; end: number; text: string }[] = [];
if (
!source.includes("![") &&
!source.includes("||") &&
!source.includes("&") &&
// Match content, header, and delimiter lines; quote-only lines count as blank.
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
source,
) &&
!(workspaceRoot && source.includes("]("))
) {
return { images, ...metadata, render: () => source };
@@ -212,11 +236,25 @@ export function prepareMarkdownSource(
if (lineEnd < end) split(lineEnd);
}
}
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
function visit(node: Nodes, previous?: Nodes) {
const position = node.position;
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
const start = position.start.offset;
const end = position.end.offset;
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
if (
node.type === "table" &&
previous?.type === "paragraph" &&
previous.position?.end.line === position.start.line - 1
) {
// MD4C only recognizes a table header on the first line of a paragraph.
const lineStart =
Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1;
const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, "");
const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd();
const boundary = lineStart - newline.length;
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
}
if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null)
@@ -244,7 +282,8 @@ export function prepareMarkdownSource(
if (text !== source.slice(start, end)) edits.push({ start, end, text });
}
}
if ("children" in node) node.children.forEach(visit);
if ("children" in node)
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
}
visit(getTree());
return {
@@ -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")}
@@ -39,7 +39,10 @@ afterEach(() => {
}
});
function buildPermission(question: Record<string, unknown>): PendingPermission {
function buildPermission(
question: Record<string, unknown>,
following: Record<string, unknown>[] = [],
): PendingPermission {
return {
key: "perm-1",
agentId: "agent-1",
@@ -48,12 +51,12 @@ function buildPermission(question: Record<string, unknown>): PendingPermission {
provider: "claude",
name: "AskUserQuestion",
kind: "question",
input: { questions: [question] },
input: { questions: [question, ...following] },
},
};
}
function mountCard(question: Record<string, unknown>) {
function mountCard(question: Record<string, unknown>, following: Record<string, unknown>[] = []) {
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
const container = document.createElement("div");
document.body.appendChild(container);
@@ -61,7 +64,7 @@ function mountCard(question: Record<string, unknown>) {
act(() =>
root.render(
<QuestionFormCard
permission={buildPermission(question)}
permission={buildPermission(question, following)}
onRespond={onRespond}
isResponding={false}
responseError={null}
@@ -71,10 +74,14 @@ function mountCard(question: Record<string, unknown>) {
mounted.push({ root, container });
const view = within(container);
const optionRole = question.multiSelect ? "checkbox" : "radio";
const otherInput = () =>
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
const check = (label: string) =>
act(() =>
(
view.queryByRole("radio", { name: label }) ?? view.getByRole("checkbox", { name: label })
).click(),
);
const type = (text: string) => {
const input = otherInput();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -90,7 +97,7 @@ function mountCard(question: Record<string, unknown>) {
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
return (response.updatedInput as { answers: Record<string, string> }).answers;
};
return { check, type, otherInput, submit, submittedAnswers };
return { check, type, otherInput, submit, submittedAnswers, view, onRespond };
}
const multiSelectQuestion = {
@@ -154,3 +161,31 @@ describe("QuestionFormCard other answers", () => {
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
});
});
describe("QuestionFormCard explicit navigation", () => {
it("keeps a picked option visible until Next and submits only on Submit", () => {
const card = mountCard(singleSelectQuestion, [multiSelectQuestion]);
card.check("Codex");
expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(card.onRespond).not.toHaveBeenCalled();
act(() => card.view.getByRole("button", { name: "Next" }).click());
card.check("Apple");
expect(card.onRespond).not.toHaveBeenCalled();
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "Codex", Fruits: "Apple" });
});
it("selects an option when its description is clicked without submitting", () => {
const card = mountCard({
...singleSelectQuestion,
options: [{ label: "Codex", description: "Use this provider" }],
});
act(() => card.view.getByText("Use this provider").click());
expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(card.onRespond).not.toHaveBeenCalled();
});
});
@@ -43,20 +43,16 @@ const IS_WEB = isWeb;
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
// Web clicks that must not answer: a link in the label, anything in the description, and the
// second click of a double-click, which would otherwise land on the next question's options.
function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean {
// Links are independent actions. Ignore repeat clicks so double-click text selection
// toggles an option only once.
function isNonAnsweringClick(event: GestureResponderEvent): boolean {
if (!IS_WEB) return false;
const click = event.nativeEvent as unknown as MouseEvent;
if (click.detail > 1) return true;
const target = click.target as Element | null;
const descriptionElement = description as unknown as Element | null;
return Boolean(target?.closest("a") || descriptionElement?.contains(target));
return Boolean(target?.closest("a"));
}
// The description is selectable text, so a touch on it belongs to the text, not the option.
const claimResponder = () => true;
function getQuestionInputPlaceholder({
question,
answerPlaceholder,
@@ -92,10 +88,9 @@ function QuestionOptionRow({
}: QuestionOptionRowProps) {
const { theme } = useUnistyles();
const descriptionRef = useRef<View>(null);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
if (isNonAnsweringClick(event, descriptionRef.current)) return;
if (isNonAnsweringClick(event)) return;
onToggle(qIndex, optIndex, multiSelect);
},
[onToggle, qIndex, optIndex, multiSelect],
@@ -152,11 +147,7 @@ function QuestionOptionRow({
<View style={styles.optionTextBlock}>
<MarkdownRenderer text={option.label} compact muted={!isSelected} selectable={false} />
{option.description ? (
<View
ref={descriptionRef}
testID="question-form-option-description"
onStartShouldSetResponder={claimResponder}
>
<View testID="question-form-option-description">
<MarkdownRenderer text={option.description} compact muted />
</View>
) : null}
@@ -413,12 +404,8 @@ export function QuestionFormCard({
});
otherInputRef.current?.replaceText("");
}
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[activeQuestionIndex, otherTexts, questions, selections],
[otherTexts, selections],
);
const setOtherText = useCallback(
@@ -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,8 +80,21 @@ export function SettingsRowHeader({
{({ hovered }: HoverState) => (
<>
<View style={settingsStyles.rowContent}>
{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>
{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}
@@ -87,11 +110,14 @@ export function SettingsRowHeader({
)}
</>
)}
</>
)}
</PressHighlight>
);
}
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);
// 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 * 2 +
geometry.fieldControlMd.paddingVertical[layout] * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlMd.minHeight);
expect(geometry.formTextInputSm.paddingVertical).toBe(5);
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
).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,
);
for (const layout of ["xs", "md"] as const) {
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBeLessThan(
geometry.buttonMd.paddingHorizontal,
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)",
+7 -49
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
@@ -12,11 +13,7 @@ import {
Terminal,
} from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -162,20 +159,13 @@ function useAnyHostOnline(serverIds: string[]): string | null {
);
}
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
export function WelcomeScreen() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
@@ -196,23 +186,10 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
router.push("/settings");
}, [router]);
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
router.push("/pair-scan?source=onboarding");
}, [router]);
const handleHostSaved = useCallback(
({ profile }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding();
},
[onHostAdded, finishOnboarding],
);
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
const actions: WelcomeAction[] =
isWeb || isFdroidBuild
@@ -306,7 +283,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<Button
variant="ghost"
size="sm"
leftIcon={Settings}
onPress={handleOpenSettings}
style={styles.settingsButton}
@@ -316,24 +292,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
<AddHostModal
visible={isDirectOpen}
onClose={handleCloseDirect}
onSaved={handleHostSaved}
/>
<AddRemoteSshHostModal
visible={isRemoteSshOpen}
onClose={handleCloseRemoteSsh}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={handleClosePasteLink}
onSaved={handleHostSaved}
/>
</ScrollView>
</View>
);
@@ -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);
});

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