Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 60a7625ad9 fix(pi): avoid delivery warnings for consumed image steers
Pi retains empty text entries in its steering queue after consuming image-only input. Require an unconsumed submission before reporting those entries as removed, and model that native behavior in the fake.
2026-11-03 22:03:52 +00:00
paseo-bot[bot] 2c3bd9e24b fix(pi): warn only for messages removed by Stop
Pi input extensions can rewrite a queued steer before it is consumed. Preserve the native clear_queue result instead of inferring removal from unmatched original text, so delivered rewrites never receive a removal warning.
2026-11-03 21:55:44 +00:00
paseo-bot[bot]andYEONGUK LEE 82d14d1e3c fix(pi): preserve steer warnings with async prompt preparation
Initialize foreground correlation only after prompt conversion completes and the turn still owns the session. This retains the stopped-steer warning without reading an uninitialized payload after integration with current main.

Co-authored-by: YEONGUK LEE <hashcode@kakao.com>
2026-11-03 21:42:57 +00:00
YEONGUK LEE 49b71c9473 refactor(pi): use explicit branches for image warning labels 2026-11-03 21:37:40 +00:00
YEONGUK LEE 860c55e59e fix(pi): distinguish foreground prompts and label image steers 2026-11-03 21:37:39 +00:00
YEONGUK LEE 19247ad6c7 fix(pi): warn when Stop clears queued steer messages 2026-11-03 21:37:39 +00:00
paseo-bot[bot] 4cd23a8f17 test(server): wait for worktree observation without racing filesystem setup (#8700)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 21:30:59 +00:00
3084f32852 Show effort controls for Claude settings models (#8699)
* fix(claude): show thinking options for models from settings.json

Models appended from Claude's settings.json were pushed to the catalog
without thinkingOptions, so the composer had no effort selector for them.
Resolve them like profile and additionalModels entries: manifest options for
built-in IDs, the standard Claude effort levels otherwise.

* docs(claude): clarify effort selection for settings models

Describe explicit effort selection without promising how every client initializes an unsaved value.

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

---------

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: wangyahao <wangyahao@fxiaoke.com>
2026-11-03 20:59:32 +00:00
paseo-bot[bot] ce8196f6c5 fix(opencode): keep reopened running conversations interruptible (#8698)
An execution adopted during session initialization emitted turn_started before the manager could subscribe. Deliver the current turn to a new subscriber so the manager owns the run and routes Stop to OpenCode.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 19:54:49 +00:00
paseo-bot[bot]andSeth Jeon 40b95f70f4 Fix Android chat mouse-wheel direction (#8693)
* fix(android): correct inverted chat wheel direction

* test(android): run wheel regressions with current React Native

Provide the native-only test context with React Native’s event dispatcher for an absent JavaScript instance, retaining real scroll handling.

Co-authored-by: Seth Jeon <paseo@sethjeon.com>

---------

Co-authored-by: Seth Jeon <paseo@sethjeon.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 19:25:40 +00:00
babo-jeongukandbabo-jeonguk 93b9ac9ded fix(claude): preserve restart recovery during graceful shutdown (#8686)
Separate runtime release from explicit turn cancellation, drain close-time cleanup before retirement, and reject late query creation. Add regressions for closure, pending tools and permissions, and asynchronous launch.

Co-authored-by: babo-jeonguk <278435975+babo-jeonguk@users.noreply.github.com>
2026-11-03 17:02:24 +00:00
paseo-ai[bot] eb8b5b8f94 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-03 12:04:22 +00:00
Okan YILDIRIMandMichael Lawson 51b9ad7695 fix(npm): declare and ship Apache-2.0 licenses (#7816)
Co-authored-by: Michael Lawson <github@mdlawson.com>
2026-11-03 11:54:57 +00:00
1c4400194a Support hardware keyboard shortcuts across native and desktop apps (#8625)
* feat(keyboard): support hardware keyboards on iPad

Register app shortcuts as iOS UIKeyCommands so a paired hardware keyboard
drives the same bindings as web and desktop. Overlays (menus, comboboxes,
modals, command center) get first refusal on bare keys, and a focused
terminal keeps the keys it needs.

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

* docs(keyboard): record iOS hardware key observation spike

With a UITextView focused on iPad Pro M4/iOS 18.6, window and controller observers received Cmd+B, Cmd+P and Escape. Escape also invoked UIKeyCommand when registered, so the stream must be the sole emitter. Use observation for shortcuts and commands only for text-control ownership. Simulator intercepted Cmd+K as HID184; input capture blocked synthetic typing entirely. Keep capture off and verify the toggle via a rebind during simulator QA.

* refactor(keyboard): give overlays one key registration API

Overlay ownership now follows one layer-ordered registry on every runtime. Web retains focus trapping and restoration through its adapter; native registers the same key handler without a DOM scope. Picker navigation and dismissal no longer require a parallel native stack.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): route native hardware keys through the shared resolver

Observe each physical press once, including native modal windows, and derive focus from declared composer and terminal surfaces. Native bindings and rebinds now use the same resolver and overlay precedence as browser and Electron input.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): show shortcuts when hardware keyboards are present

Keep routing independent of compact layout and detect hardware capability for hints and settings. Rebind capture uses the shared overlay interface on native too. Keep the filtered sidebar model available for keyboard navigation while its panel is hidden.

Co-authored-by: Les Stroud <public@lesstroud.com>

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* test(keyboard): allow shortcut hints in compact menu assertions

Compact menus now display hardware keyboard hints. Assert action labels, order and uniqueness without treating a trailing shortcut badge as part of the action label.

* fix(keyboard): retain an overlay's opening layer in state

Capture the registry's current top when an overlay opens and retain that layer through rerenders. React Compiler removed the previous useMemo cache after the registry getter moved across modules, allowing a parent modal to rise above its child and start an infinite focus loop.

Compile this hook in Vitest as Expo does. The new hook regression failed first with layer 220 instead of 20, then passed with explicit opening state. Focused unit and browser tests pass, and the previously failing onboarding E2E completes through the real Expo compiler. Typecheck, lint and format pass.

* refactor(keyboard): let overlays own native hardware input

Remove the TextInput key handlers that duplicate declared overlay arrows and Escape in Add Project and Command Center. Remove Add Project's hidden responder now that native window sources observe keys without it. Keep soft-keyboard submit editing behavior.

* fix(keyboard): close Add Project outside its state updater

Root Escape previously called the parent close action from a React state updater, producing a cross-component render warning when navigation was still queued. Compute the previous page in the event handler and close outside the updater.

The existing Escape/Back E2E now batches navigation and dismissal through the DOM key source. It failed first with the AddProjectFlowHost render warning, then passed after the correction. Typecheck, lint and format pass; native Escape checks pass on Android and iPad.

* fix(keyboard): preserve Android dead-key composition

* fix(keyboard): recognize focused native text inputs

* test(keyboard): replace JSDOM coverage with browser tests

* refactor(menu): spell out arrow navigation branches

* fix(composer): claim fullscreen Escape on native

* refactor(composer): one fullscreen Escape module for every platform

The shared registration lived in fullscreen-exit.shared.ts with two one-line
re-exports (fullscreen-exit.ts and fullscreen-exit.native.ts). Metro needs no
native override now that the Android back button is its own adapter, so the
implementation moves into fullscreen-exit.ts and the browser test imports the
module every platform resolves.

---------

Co-authored-by: Ubuntu <jeroenfrenken@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>
2026-11-03 18:08:27 +07:00
Dominic 1502b7f90f fix(acp): fail a prompt to a disconnected ACP agent instead of hanging (#8406)
When the ACP agent process exited while idle, the session kept its dead
connection and accepted the next prompt. The ACP SDK logs the failed
write but never settles the prompt request, so the turn stayed running
forever with no error.

startTurn now refuses a connection whose signal has aborted, and a turn
whose transport closes before its response fails with "ACP agent
disconnected" (with the exit code or signal when known). A response
that was already delivered still completes the turn, and a crash that
the exit handler reports keeps its "exited unexpectedly" error.

Fixes #7070
2026-11-03 10:08:51 +00:00
paseo-bot[bot] 16f688b726 test(server): stop pending setup before removing its fixture (#8624)
* test(server): diagnose recurring Windows setup timeout

* test(server): stop pending setup before removing its fixture

A test deadline does not cancel background setup. Abort and join it before deleting its working directory, and cover teardown with a deliberately live setup child. Keep the existing subdirectory shell assertions and timeout.

* test(server): preserve setup readiness filesystem errors

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 09:30:30 +00:00
paseo-bot[bot]andHomicideZero b507fc1986 fix(server): return 404 for missing well-known metadata (#8622)
* fix(server): return 404 for unserved well-known paths

The daemon web UI middleware fell back to the SPA's index.html for every
unknown GET, including /.well-known/* URIs. MCP clients that run OAuth
discovery (RFC 9728 protected resource metadata, RFC 8414 authorization
server metadata) received a 200 text/html body, treated it as malformed
metadata and retried instead of concluding the server has no OAuth.

Well-known paths now serve a real file from the dist directory if one
exists and otherwise return a JSON 404. Other paths keep the SPA fallback.

* fix(server): verify metadata routing through the daemon

Retain the contributed well-known routing fix with named resolver options and a full daemon regression that fails when metadata requests receive app HTML.

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>

---------

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 07:53:13 +00:00
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
paseo-bot[bot]andNoah efc64a5443 Explain the mode required to create agents on another provider (#8483)
* fix(server): say when create_agent needs an explicit mode

* test(server): verify cross-provider mode discovery through the daemon

Exercise the MCP contract, refusal without a child, provider inspection, and explicit-mode creation through the public daemon interfaces. Qualify unattended advice because targets without an unattended mode still require an explicit choice.

Co-authored-by: Noah <noahguerin20@gmail.com>

* test(server): run mode discovery in the integration suite

The unit command excludes E2E files, and CI integration uses an explicit list. Add the cross-provider MCP regression to that existing list so CI exercises its guidance, refusal, discovery, and retry assertions.

---------

Co-authored-by: Noah <noahguerin20@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 02:08:31 +00:00
7efa14238d fix(claude): report API-error turns as failed (#8480)
* fix(claude): fail a turn that Claude Code ends on an API error

Claude Code reports a turn that stopped on an API error (a session or
usage limit, an invalid key, an overloaded request) as a `success`
result with `is_error: true`, the error text in `result`, after a
synthetic assistant reply carrying the same text and an `error` field.
The provider read only the subtype, so the turn completed with the
error as its answer: the agent went idle with no lastError and showed
as finished in the sidebar.

Treat such a result as a failed turn with the result text as its
error. Hold the synthetic error reply back until the result arrives so
the failure row does not repeat it; a reply the turn moves past, or a
result without is_error, still shows it.

Co-authored-by: RoundRedDot <roundreddot@gmail.com>

* fix(claude): retain final usage when an API-error turn fails

Publish the converted result totals before the failure event so the
existing usage handler preserves work done earlier in the turn.

---------

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 01:07:00 +00:00
paseo-bot[bot] dbbcb103ba fix(opencode): preserve denial reasons when a message supersedes approval (#8477)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 23:50:55 +00:00
Thomas VanandClaude Opus 5.5 7f318bf7cf fix(codex): end an interrupted turn's shell commands (#7525)
`turn/interrupt` ends a Codex turn and leaves the shell commands it started running: a
command the user just stopped runs on to its own end, alongside the next turn.

Once Codex accepts the interrupt, the provider now ends each command the interrupted turn
started that has not exited, by process id, with `thread/backgroundTerminals/terminate`,
and a turn Paseo starts waits for those calls before its `turn/start`.

- Attribution: `thread/backgroundTerminals/list` carries no turn, so the provider records
  each root-thread `commandExecution` process id under the turn of its `item/started`. A
  `completed` item drops its id; a `failed` one keeps it, because Codex reports an
  interrupted turn's still-running command as failed. A turn that ends any way but
  `interrupted` forgets its ids, so what an earlier turn left running on purpose is never
  ended. At most 16 turns are remembered.
- A late command: the turn keeps its accepted interrupt, so a command whose `item/started`
  arrives after Codex accepted the interrupt (one queued behind an earlier notification's
  conversion, for instance) is ended as soon as it is seen. Each process is terminated
  once, and a turn start waits for every pending sweep.
- It never sends `thread/backgroundTerminals/clean`, which ends every terminal on the
  thread. It ends nothing after an already-idle interrupt, nothing on a subagent's thread,
  and nothing after an interrupt of a turn Codex never identified.
- Bounds: each terminate times out after 2 s; a failure is logged
  (`provider.codex.interrupt.terminal_terminate_failed`) and fails nothing. `startTurn`
  waits at most the interrupt's bound plus one terminate's plus 500 ms.

Tests (codex-interrupted-turn-terminals.test.ts) drive a real CodexAppServerAgentSession
against the scripted app-server in codex/test-utils/fake-app-server.ts: exactly the
interrupted turn's processes are terminated, by id, and `clean` is never sent; a turn
Codex starts before acknowledging the interrupt keeps its commands; the next `turn/start`
waits for the terminates; a command announced after the interrupt was accepted is
terminated once and the next turn waits for both sweeps, whichever answers first; a
completed command is forgotten and a failed one kept; only the latest sixteen accepted
interrupts are kept; a failing or unanswered terminate does not block the next turn. The
harness waits for the interrupted turn's `turn_canceled` before it starts the next turn,
as a caller does: notifications are handled asynchronously, so the interrupt's answer can
arrive before the turn has ended.

Fixes #7522


Claude-Session: https://claude.ai/code/session_01Ay568T2kmtotoh23Dtzi5K

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:58:12 +00:00
paseo-bot[bot] 04fe4be40f fix(claude): retain configured model metadata before a turn (#8465)
Rebuilding an idle session cleared its observed model and published null despite retaining an explicit model in its configuration. Fall back to that configuration until the next turn supplies an observed model.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:59:04 +00:00
paseo-bot[bot] 2d75cc80b2 test(server): cover routed permissions in one host journey (#8486)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:34:57 +00:00
paseo-bot[bot]andDominic Anaeto bfac132920 Stop Git polling while workspace folders are missing (#8462)
* fix(server): stop polling git for a workspace whose folder is gone

When a workspace's folder disappears together with its project (the
repository deleted, a volume unmounted), reconciliation leaves the
workspace registered, and the daemon kept polling it: a failed git
command and a warning about once a minute, and a working-tree watcher
retry every minute or two, for as long as the daemon ran.

The degraded poll now checks that the workspace folder exists before
refreshing. A workspace whose folder is gone is suspended once: it is
detached from its repository and working-tree targets, its timers stop,
and one warning is logged. A worktree that shared a repository target
hands the repository's polling and background fetch to a surviving
worktree. Nothing is archived. A working-tree watch that an open diff
still holds skips its consumer notifications and watcher recovery while
its folder is missing.

While suspended, the workspace runs no git. A stat every 60 seconds, a
new subscription, a forced snapshot or a refresh request checks whether
the folder is back; when it is, the workspace reloads its git facts,
refreshes, and rebuilds its observation.

Fixes #7856

* fix(server): keep missing workspaces suspended during refresh

Admit Git refreshes only after the folder has returned. Cover missing-folder polling, explicit refreshes, and restoration with real Git and the Linux observer.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* fix(server): preserve workspace snapshots through suspension

Drop queued refreshes and withhold publication when a folder disappears during an active read. Wait for that read before restoring observation so its stale result cannot overwrite the recovered snapshot.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:02:26 +00:00
paseo-bot[bot]andDominic Anaeto 34f996b32a Preserve pending questions and approvals after peer messages (#8453)
* fix(server): keep a recipient's pending permission when another agent messages it

A message from another agent (send_agent_message with sourceAgentId) was
enqueued with clearPendingPermissions: true, so it superseded the
recipient's pending permission request and the request was denied before
a human saw it. Agent-sourced messages now leave pending permissions
alone. Typed messages and sourceless `paseo send` still clear them.

Fixes #7602

* test(server): verify peer messages preserve answerable questions

Exercise message delivery through the daemon and provider interfaces so the regression checks that the question survives and its answer reaches the provider. Retain the human-steer cancellation behavior.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* test(terminal): make runtime copy fixture independent of umask

Set the source helper permissions after creation so the copy test measures preservation instead of inheriting the shell file-creation mask. The existing test fails under umask 077 without this setup correction.

Fixes #8175

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 17:11:42 +00:00
3e83f433da Import large Claude sessions without exceeding the string limit (#8457)
* fix(claude): read the session rename from the end of a large transcript

Importing a Claude session read the whole transcript into one UTF-8 string to
look for its `custom-title` record, so a transcript past V8's maximum string
length failed the import with `RangeError: Invalid string length` before the
incremental history reader ever ran. The scan now reads the file backwards a
chunk at a time, which keeps the original intent of not walking the transcript
and never holds it as one string. Records without the needle are not decoded, so
a transcript with no rename costs a byte search per chunk.

Closes #8451

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

* test(app): wait for send readiness in composer recall

The previous prompt can appear and clear its draft before the send acknowledgement arrives. Wait for Send to be enabled before pressing Enter so recall setup does not silently leave the next prompt unsent.

Co-authored-by: qwist1233-cpu <qwist1233@gmail.com>

* fix(claude): scan large transcript records once

Retain unfinished record chunks and assemble each record once instead of copying and rescanning a growing buffer on every backward read. Cover import of a large record after the last explicit rename.

---------

Co-authored-by: qwist1233-cpu <qwist1233@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 15:56:42 +00:00
paseo-bot[bot] 674530152e test(server): cover filename downloads in one workspace journey (#8475)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 15:31:08 +00:00
Dominic 919e44b319 fix(server): start configured plugins concurrently (#8427)
The daemon accepts connections only after its configured plugins have
started, and PluginService.start started them one at a time, so each
plugin's build and process launch added to daemon startup. Configured
plugins now start together. Their providers and usage sources are still
published in config order: a plugin that finishes early waits for the
plugins listed before it, so when two plugins claim the same provider or
usage-source ID, the one listed first keeps it, as before. A failing
plugin still fails on its own.

Fixes #7979
2026-11-01 12:59:34 +00:00
paseo-bot[bot] 4fe44038e3 test(server): check subdirectory diffs before and after commit (#8466)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 12:35:50 +00:00
Dominic 9b0542d127 fix(server): retry a connected host that failed the capability check (#8423)
* fix(server): retry a connected host that failed the capability check

Every failure in a connected host's acceptance step was terminal: the
row went to "error" with no retry, and the status listener ignored
later reconnects. A host rejected with unsupported_capability stayed
unusable after it was updated, until the registration was saved again
or the managing daemon restarted. Capability and other non-identity
acceptance failures now go "offline" with nextRetryAt and retry with
capped backoff (1.5 s doubling to 30 s), so an updated host comes
online by itself. Self, duplicate, changed-identity and authentication
failures stay terminal.

Fixes #7971

* fix(server): keep a retried host's relay security floor and update guidance

A scheduled capability retry built a new transport factory, which
dropped the relay security floor learned from a protected handshake, so
the retry could negotiate legacy encryption and send credentials.
Retries now reuse the registration's transport factory (createHostClient
accepts one). The update guidance also stays visible while a retry
connects, the backoff resets after a successful acceptance, and
disabling or removing a host cancels its pending retry.
2026-11-01 10:59:33 +00:00
Dominic 8130147426 fix(app): stop the terminal from typing its version reply into the shell (#8419)
The browser terminal is a renderer and leaves protocol replies to the
daemon, but it still answered XTVERSION (CSI > q) and DECRQSS
(DCS $ q ... ST). Those replies went out through onData as if the user
had typed them, so a query from a program in the shell left text like
">|xterm.js(6.1.0-beta.292)" on the next input line. Both queries are
now swallowed with the other protocol queries.

Fixes #8111
2026-11-01 10:04:25 +00:00
Mohamed Boudra fa89738e3a chore(desktop): upgrade packaging tool and remove signing backport (#8450)
* chore(desktop): upgrade packaging tool and remove signing backport

* fix(nix): resolve the desktop workspace packaging tool
2026-11-01 14:46:52 +07:00
paseo-bot[bot]andDominic Anaeto 14519bdb5b Read archived Pi chats after their working folder is removed (#8442)
* fix(pi): open an archived Pi agent's history after its folder is removed

Opening an archived agent's timeline resumes its provider for history.
Pi's resumeSession never received the resume purpose, so it launched the
Pi RPC process, and its MCP support probe, in the agent's stored working
directory. When that folder had been removed, for example with a merged
worktree, Node failed the spawn with "spawn <command> ENOENT" and the
history could not be read.

Starting the process elsewhere is not enough: with --session, Pi checks
the cwd stored in the session file's header and, outside interactive
mode, exits when that folder is missing. A history resume whose stored
folder is no longer a directory now starts Pi from the home directory on
a temporary copy of the session file whose header names that directory.
The copy streams everything after the header unchanged and is deleted
when the session closes. The agent keeps reporting its stored folder and
original session file, and the original file is not written to.

The directory check moves out of the ACP provider, which already starts
history loads from the home directory (#6228), into a shared helper both
providers use.

Fixes #7969

* test(terminal): keep the argv test child alive through its assertion

The POSIX argv test reads the terminal screen while its child can exit and dispose the parser. Keep the child alive until the assertion completes, as other screen tests do, and let the existing session cleanup terminate it.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:52:48 +00:00
Mohamed Boudra 891e62309c fix(release): use the keychain password for macOS signing setup (#8445)
Backport the pinned packaging tool correction in the workflow so existing release tags can be rebuilt without changing their dependencies.
2026-11-01 13:35:11 +07:00
paseo-bot[bot] 6ae6308cfb test(server): control failed Git clone responses in credential test (#8428)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:01:30 +00:00
Mohamed Boudra ef77b823ae chore(release): reconcile 0.15.0-beta.1 metadata (#8443)
* docs(changelog): prepare 0.15.0-beta.1

(cherry picked from commit 7e5ad53491)

* chore(release): cut 0.15.0-beta.1

(cherry picked from commit 8bd38fb236)

* test(docs): follow nested worktrees page
2026-11-01 12:56:41 +07:00
Mohamed Boudra 60f29a4350 docs(worktrees): add nested worktreeinclude reference 2026-11-01 06:31:01 +01:00
paseo-bot[bot]andDominic Anaeto 8efb21f55c fix(server): close abandoned workspace service connections (#8421)
* fix(server): close the upstream connection when a proxied client leaves

The service proxy piped the service's response to the client but never
destroyed the upstream request when the client went away. The upstream
socket stayed open and unread, so a Server-Sent Events service kept
writing into it and grew in memory. The proxy now destroys the upstream
request when the client response closes before it finished, without
logging that deliberate abort as an unreachable upstream, and a
WebSocket upgrade closes the service socket when the client socket
closes.

Fixes #8149

* test(server): publish terminal activity fixture atomically

File existence did not mean the child had finished writing its JSON. Publish the completed fixture by rename so a concurrent reader cannot parse an empty file.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* test(server): keep rollback listener ports distinct

Selecting both free ports before binding either lets the OS return the same port twice. Hold the intentionally occupied main port before selecting the standalone port so the rollback probe cannot reach the wrong server.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 03:55:48 +00:00
paseo-ai[bot] b2666a0aec fix: update lockfile signatures and Nix hash [skip ci] 2026-11-01 03:43:24 +00:00
395 changed files with 19982 additions and 4818 deletions
-26
View File
@@ -135,32 +135,6 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Backport electron-builder#10172 to the pinned build tool only.
# Remove this step when app-builder-lib is upgraded to a release containing it.
- name: Repair temporary keychain password in signing tool
shell: bash
run: |
node <<'KEYCHAIN_FIX'
const fs = require('node:fs');
const path = require('node:path');
const manifestPath = require.resolve('app-builder-lib/package.json');
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
}
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
let source = fs.readFileSync(sourcePath, 'utf8');
const replacements = [
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
];
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
source = source.replace(before, after);
}
fs.writeFileSync(sourcePath, source);
KEYCHAIN_FIX
- name: Set desktop package version from tag
shell: bash
run: |
-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",
-23
View File
@@ -1,28 +1,5 @@
# Changelog
## 0.15.2 - 2026-11-05
### Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363))
### Improved
- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363))
## 0.15.1 - 2026-11-05
### Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810))
## 0.15.0 - 2026-11-03
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
-23
View File
@@ -1,28 +1,5 @@
# Changelog
## 0.15.2 - 2026-11-05
### 新增
- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
### 改进
- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
## 0.15.1 - 2026-11-05
### 修复
- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810))
## 0.15.0 - 2026-11-03
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
+1
View File
@@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md) | The binding table, per-platform key sources, badges vs routing, iOS `UIKeyCommand` limits |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
+5 -1
View File
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline selection
## Inverted timeline input
Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
@@ -183,6 +183,10 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior.
Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical
wheel axis for inverted scroll views before delegating to Android, preserving native scroll
bounds and React Native's scroll events and disabled-scroll behavior.
Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
+4 -1
View File
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance.
operations; requesting this feature gives update guidance. The registry retries an older destination
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
authentication failures stay in `error` until an update changes the connection, `enabled`, or
`replaceIdentity`, or A restarts.
Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
+2
View File
@@ -777,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
+4 -4
View File
@@ -35,7 +35,7 @@ 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`).
- **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.
@@ -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. 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>` 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.
@@ -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
+86
View File
@@ -0,0 +1,86 @@
# Keyboard shortcuts
Define shortcuts in [the shared binding table](../packages/app/src/keyboard/keyboard-shortcuts.ts). Platforms contribute key sources, not separate action maps, so matching and user rebinds stay consistent across runtimes. Add a shortcut through a binding; add platform support through a key source.
## Badges and routing
Routing runs whenever the app mounts its key sources, independently of layout.
Use `useHardwareKeyboardPresent()` for hints and the Shortcuts settings section.
iOS reads `GCKeyboard.coalesced` and connection notifications. Android watches input
devices and hardware keyboard configuration. Web starts with a fine hover pointer and recognizes the first modified
keydown, including on touch devices. Compact Electron keeps its bindings.
Keyboard navigation needs the sidebar's filtered workspace model even when its visual
panel is hidden. Retain that model while keyboard navigation is active; gating it on
panel visibility leaves number shortcuts with no targets after a compact startup.
Composer send-key customization remains a separate capability. Native retains its
existing Return submission path; advertising its web-only customization would expose
settings the native composer does not implement.
## Native sources
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
that must bypass the focused text control: composer Return and the top overlay's
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
press is still observed by the window.
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
`UITextView` first responder. Both `UIWindow.sendEvent` and the root controller's
`pressesBegan` received Cmd+B, Cmd+P and Escape. With Escape registered as a
`UIKeyCommand`, the window observed it and the command handler also ran.
Simulator reserves Cmd+K for Toggle Software Keyboard. Injecting it produced
HID 184 with empty characters rather than K; plain K, Cmd+B and Cmd+P arrived
normally. Keep Simulator input capture off: Send Keyboard Input to Device
prevented synthetic AppleScript input from arriving, including plain text.
For simulator QA, use Cmd+B/P to check delivery and rebind the command center to
Cmd+J to check toggle and rebind behavior. Cmd+K delivery needs a physical iPad.
Simulator also reserves Cmd+1…4, Cmd+Shift+A and Cmd+comma. Exercise those actions
through rebinds. For numeric injection, use AppleScript physical key codes (for example
25 for 9); `keystroke "9"` did not deliver a digit in the QA session.
Simulator exposes a virtual `GCKeyboard` even with Connect Hardware Keyboard off.
Verify the iOS absence case on a physical iPhone.
Never register Backspace: it would take deletion away from text fields. Changes
to the native module require a native rebuild; an over-the-air JavaScript update
cannot deliver them.
## Overlay ownership
Register keyboard handling with `useOverlayRegistration` from `@/keyboard`.
Pass the surface's open state, layer, handled keys and `onKeyDown`. The highest
layer gets first refusal; the latest registration wins a tie. Native registration
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
Capture a global overlay’s layer in state for its opening lifetime. Memo caches can
be discarded or removed by React Compiler; recomputing against the registry lets an
open modal rise above its own children and creates competing focus traps.
Declare Enter and arrow keys only while the surface owns them. Native sources
use the top overlay's declarations to take those keys before text controls edit.
Backspace stays with the focused input. Composition confirmation must not select
an overlay row or submit the composer.
Android modals have their own windows. Observe them through React Native's
`ExtraWindowEventListener` as well as the activity window. The dev client bypasses
Expo's root-container handler, so an activity root-view wrapper misses presses.
A modal can forward an unhandled event into the activity window. Observe the event
once across that nested delivery without retaining recycled Android `KeyEvent`s.
Keep an overlay-owned press consumed through key-up, even if its key-down closes
the overlay; otherwise the modal's default Escape handler can close a second layer.
iOS scene startup installs the observing window after Expo creates its root view.
Keep that integration in the hardware keyboard module's config plugin. Its window
owns both the press stream and the text-control key commands, including above modals.
## Focus scopes
The composer and terminal declare focus with `useKeyboardFocusSurface`. Native
scope derives from those declarations and command-center state. Releasing an old
surface must not clear a newer owner's declaration during a tab switch. Web keeps
its DOM-derived scope.
Escape interrupts an agent only in the message input or an unclaimed surface.
A focused terminal excludes that binding. The source leaves terminal Escape alone;
terminal Escape/Ctrl forwarding is a separate feature.
+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.
@@ -1,4 +0,0 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -127,7 +127,7 @@ buildNpmPackage {
# The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \
../../node_modules/.bin/electron-builder \
npm exec --offline -- electron-builder \
--config electron-builder.yml \
--dir \
--mac \
+1 -1
View File
@@ -1 +1 @@
sha256-YLrbUckIiyV7esu2tmiKdLL0YHsvwqcA0Xe0gSeMCC0=
sha256-VdM4lXdgNEXPpTfvHSFvF9nm302rK/ZbVkZUp9tyOfg=
+1053 -1323
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.2",
"version": "0.15.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+1
View File
@@ -142,6 +142,7 @@ export default {
searchPaths: ["../../node_modules", "./node_modules"],
},
plugins: [
"./modules/paseo-hardware-keyboard/app.plugin",
"expo-router",
withPasteInput,
withAndroidScroll,
+18
View File
@@ -94,3 +94,21 @@ it("allows all orientations on iPhone and iPad", async () => {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
// Scene startup is the integration boundary for the iOS keyboard window.
describe("hardware keyboard scene integration", () => {
it("installs observation after Expo creates the scene window and is idempotent", () => {
const {
configureHardwareKeyboardAppDelegate,
} = require("./modules/paseo-hardware-keyboard/app.plugin.js");
const source =
"import Expo\nclass Scene {\n super.scene(scene, willConnectTo: session, options: connectionOptions)\n}\n";
const configured = configureHardwareKeyboardAppDelegate(source);
expect(configured).toContain("import PaseoHardwareKeyboard");
expect(configured).toContain("PaseoHardwareKeyboardWindow.observing(window)");
expect(configured.indexOf("super.scene")).toBeLessThan(
configured.indexOf(".observing(window)"),
);
expect(configureHardwareKeyboardAppDelegate(configured)).toBe(configured);
});
});
@@ -5,6 +5,7 @@ import path from "node:path";
import { test, expect } from "../support/fixtures";
import {
addProjectFlow,
chooseAddHostFromProjectFlow,
addProjectFlowBack,
addProjectFlowHost,
addProjectFlowInput,
@@ -14,11 +15,14 @@ import {
expectNewWorkspaceForAddedProject,
expectAddProjectResultVisible,
navigateAddProjectResults,
navigateAndDismissAddProject,
watchAddProjectRenderWarnings,
openAddProjectFlow,
openAddProjectHostSelection,
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
disconnectAllHostsAndReload,
addConnectedHostAndReload,
addConnectedHostsAndReload,
addOfflineHostAndReload,
@@ -69,6 +73,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,
}) => {
@@ -131,6 +148,7 @@ test.describe("Add Project command-center flow", () => {
test("Escape and Back restore searchable page input before closing at the root", async ({
page,
}) => {
const renderWarnings = watchAddProjectRenderWarnings(page);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
@@ -168,8 +186,9 @@ test.describe("Add Project command-center flow", () => {
await page.keyboard.press("Escape");
await expectAddProjectPage(page, "host");
await page.keyboard.press("Escape");
await navigateAndDismissAddProject(page);
await expect(addProjectFlow(page)).not.toBeVisible();
expect(renderWarnings).toEqual([]);
});
test("New directory creates a Project on the selected remote host", async ({ page }) => {
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { test, expect, type Page } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
@@ -59,6 +60,14 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) {
return menu;
}
async function expectActionsInOrder(menu: Locator, labels: readonly string[]) {
const items = menu.getByRole("menuitem");
await expect(items).toHaveCount(labels.length);
for (const [index, label] of labels.entries()) {
await expect(items.nth(index).getByText(label, { exact: true })).toBeVisible();
}
}
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-pull-");
cleanupTasks.push(async () => {
@@ -83,12 +92,7 @@ test("compact git actions menu lists a promoted Pull once", async ({ page }) =>
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Pull",
"Push",
"Pull and push",
"Archive workspace",
]);
await expectActionsInOrder(menu, ["Pull", "Push", "Pull and push", "Archive workspace"]);
});
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
@@ -106,10 +110,5 @@ test("compact git actions menu lists a promoted Push once", async ({ page }) =>
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Push",
"Pull",
"Pull and push",
"Archive workspace",
]);
await expectActionsInOrder(menu, ["Push", "Pull", "Pull and push", "Archive workspace"]);
});
@@ -3,7 +3,7 @@ import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
submitMessage,
fillComposerDraft,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendDraftWhenReady(page: Page) {
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await pressInComposer(page, "Enter");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await submitMessage(page, text);
await fillComposerDraft(page, text);
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter");
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
@@ -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
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
await expect
.poll(async () => {
const labelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
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();
}
-137
View File
@@ -12,15 +12,11 @@ import {
openAppAsNewUser,
openRelayPairingFromSetup,
closeRelayPairing,
emptyHome,
loadingScreen,
openTailscaleDocsFromSetup,
paseoToolsSwitch,
pickTheme,
projectSuggestion,
projectSuggestionRow,
readFirstLaunch,
recordFirstLaunch,
setupModal,
setupPrimaryButton,
showSetupAgainFromSettings,
@@ -103,139 +99,6 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
}
});
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
const host = await startSetupHost("first-launch");
try {
await recordFirstLaunch(page);
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
const launch = await readFirstLaunch(page);
expect(launch.appBeforeSetup).toBe(false);
expect(launch.setupOpacityOnArrival).toBeLessThan(1);
await expect(setupModal(page)).toHaveCSS("opacity", "1");
} finally {
await host.close();
}
});
test("with reduced motion, setup appears without fading in", async ({ page }) => {
const host = await startSetupHost("reduced-motion");
try {
await page.emulateMedia({ reducedMotion: "reduce" });
await recordFirstLaunch(page);
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1);
} finally {
await host.close();
}
});
test("changing the theme keeps the setup window in place on every step", async ({ page }) => {
const host = await startSetupHost("theme-change");
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
const setupWindow = await setupModal(page).elementHandle();
for (const title of [
"Welcome to Paseo",
"Your agents",
"Add your projects",
"Let agents use Paseo",
"Take your agents with you",
]) {
if (title !== "Welcome to Paseo") await continueSetup(page);
await expectSetupStep(page, title);
const heading = setupModal(page).getByRole("heading", { name: title });
const headingNode = await heading.elementHandle();
const lightColor = await heading.evaluate((node) => getComputedStyle(node).color);
await page.emulateMedia({ colorScheme: "dark" });
await expect(heading).not.toHaveCSS("color", lightColor);
await page.emulateMedia({ colorScheme: "light" });
await expect(heading).toHaveCSS("color", lightColor);
expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true);
expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true);
}
await goToSetupStep(page, "Appearance");
await pickTheme(page, "Midnight");
await pickTheme(page, "Light");
expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true);
} finally {
await host.close();
}
});
test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => {
const host = await startSetupHost("slow-workspaces");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
try {
await recordFirstLaunch(page);
gate.holdNextClientRequest("fetch_workspaces_request");
await openAppAsNewUser(page, host);
await gate.waitForHeldClientRequest();
await expect(loadingScreen(page)).toBeVisible();
await expect(emptyHome(page)).toHaveCount(0);
gate.releaseHeldClientRequest();
await expectSetupStep(page, "Welcome to Paseo");
expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false);
} finally {
await host.close();
}
});
test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => {
const host = await startSetupHost("stuck-workspaces");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
try {
gate.holdNextClientRequest("fetch_workspaces_request");
await openAppAsNewUser(page, host);
await gate.waitForHeldClientRequest();
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
await expect(setupModal(page)).toHaveCount(0);
// Setup still opens once the host answers.
gate.releaseHeldClientRequest();
await expectSetupStep(page, "Welcome to Paseo");
} finally {
await host.close();
}
});
test("a setup host that drops while loading still lands on the home", async ({ page }) => {
const host = await startSetupHost("dropped-workspaces");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
try {
gate.holdNextClientRequest("fetch_workspaces_request");
await openAppAsNewUser(page, host);
await gate.waitForHeldClientRequest();
await gate.drop();
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
await expect(setupModal(page)).toHaveCount(0);
} finally {
await host.close();
}
});
test("an unreachable setup host lands on the home without setup", async ({ page }) => {
const host = await startSetupHost("unreachable");
await host.close();
await openAppAsNewUser(page, host);
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
await expect(setupModal(page)).toHaveCount(0);
});
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);
@@ -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();
});
});
});
@@ -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";
@@ -276,3 +281,71 @@ test("restart recovery defaults off and persists opt-in and custom prompt", asyn
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 });
}
@@ -113,3 +113,29 @@ 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();
}
export async function navigateAndDismissAddProject(page: Page): Promise<void> {
// Keep both keys in one task so Escape closes with a selection update still queued.
await page.evaluate(() => {
for (const key of ["ArrowDown", "Escape"]) {
window.dispatchEvent(
new KeyboardEvent("keydown", { key, code: key, bubbles: true, cancelable: true }),
);
}
});
}
export function watchAddProjectRenderWarnings(page: Page): string[] {
const warnings: string[] = [];
page.on("console", (message) => {
const text = message.text();
if (text.includes("Cannot update a component") && text.includes("AddProjectFlow")) {
warnings.push(text);
}
});
return warnings;
}
@@ -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();
}
@@ -34,54 +34,6 @@ export async function openAppAsNewUser(
await page.goto("/");
}
interface FirstLaunchRecord {
appBeforeSetup: boolean;
setupOpacityOnArrival: number | null;
}
/**
* Watches the page from its first paint: whether the app's sidebar or home showed before setup,
* and how opaque setup was when it arrived. Call before `openAppAsNewUser`.
*/
export async function recordFirstLaunch(page: Page): Promise<void> {
await page.addInitScript(() => {
const record: FirstLaunchRecord = { appBeforeSetup: false, setupOpacityOnArrival: null };
Object.assign(window, { __firstLaunch: record });
const observer = new MutationObserver(() => {
const setup = document.querySelector('[data-testid="onboarding-modal"] [role="dialog"]');
if (setup) {
record.setupOpacityOnArrival = Number(getComputedStyle(setup).opacity);
observer.disconnect();
return;
}
if (
document.querySelector(
'[data-testid="open-project-submit"], [data-testid="sidebar-settings"]',
)
) {
record.appBeforeSetup = true;
}
});
observer.observe(document, { childList: true, subtree: true });
});
}
export async function readFirstLaunch(page: Page): Promise<FirstLaunchRecord> {
return page.evaluate(
() => (window as unknown as { __firstLaunch: FirstLaunchRecord }).__firstLaunch,
);
}
/** The loading screen the app shows while it starts. */
export function loadingScreen(page: Page): Locator {
return page.getByTestId("startup-splash");
}
/** The empty home a host with no projects lands on. */
export function emptyHome(page: Page): Locator {
return page.getByTestId("open-project-submit");
}
export function setupModal(page: Page): Locator {
return page.getByTestId("onboarding-modal").getByRole("dialog");
}
@@ -0,0 +1,16 @@
apply plugin: 'com.android.library'
apply plugin: 'expo-module-gradle-plugin'
group = 'sh.paseo.keyboard'
version = '0.1.0'
android {
namespace 'sh.paseo.keyboard'
defaultConfig {
versionCode 1
versionName '0.1.0'
}
}
dependencies {
implementation 'com.facebook.react:react-android'
testImplementation 'junit:junit:4.13.2'
}
@@ -0,0 +1,7 @@
package sh.paseo.keyboard
import android.view.KeyCharacterMap
internal fun hardwareCharacterKey(character: Int): String =
if (character and KeyCharacterMap.COMBINING_ACCENT != 0) "Dead"
else String(Character.toChars(character))
@@ -0,0 +1,164 @@
package sh.paseo.keyboard
import android.content.ComponentCallbacks
import android.content.res.Configuration
import android.hardware.input.InputManager
import android.os.Handler
import android.os.Looper
import android.view.InputDevice
import android.view.KeyEvent
import android.view.Window
import android.widget.EditText
import android.view.inputmethod.BaseInputConnection
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.bridge.ReactContext
import com.facebook.react.interfaces.ExtraWindowEventListener
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
internal object HardwareKeyboard {
var module: PaseoHardwareKeyboardModule? = null
@Volatile var overlayKeys: Set<String> = emptySet()
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
val dispatcher = WindowKeyDispatcher<KeyEvent>()
private val consumedPresses = mutableSetOf<Int>()
fun observe(window: Window) {
if (windows.containsKey(window)) return
val callback = KeyboardWindowCallback(window, window.callback)
windows[window] = callback
window.callback = callback
}
fun release(window: Window) {
val callback = windows.remove(window) ?: return
if (window.callback === callback) window.callback = callback.previous
}
fun releaseAll() {
windows.keys.toList().forEach { release(it) }
consumedPresses.clear()
}
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
val code = code(event.keyCode)
if (code.isEmpty()) return false
val textModifiers = event.metaState and KeyEvent.META_CTRL_MASK.inv() and KeyEvent.META_META_MASK.inv()
val character = event.getUnicodeChar(textModifiers)
val named = !code.startsWith("Key") && !code.startsWith("Digit")
val key = when {
code.startsWith("Control") -> "Control"
code.startsWith("Meta") -> "Meta"
code.startsWith("Shift") -> "Shift"
code.startsWith("Alt") -> "Alt"
named && (character == 0 || code == "Enter" || code == "Tab" || code == "Backspace") -> code
character != 0 -> hardwareCharacterKey(character)
else -> code
}
module?.emit(mapOf(
"key" to key, "code" to code,
"altKey" to event.isAltPressed, "ctrlKey" to event.isCtrlPressed,
"metaKey" to event.isMetaPressed, "shiftKey" to event.isShiftPressed,
"repeat" to (event.repeatCount > 0), "isComposing" to composing,
"phase" to if (event.action == KeyEvent.ACTION_UP) "up" else "down"
))
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
return overlayOwnsKey
}
private fun code(keyCode: Int): String = when (keyCode) {
in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> "Key" + ('A'.code + keyCode - KeyEvent.KEYCODE_A).toChar()
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> "Digit" + (keyCode - KeyEvent.KEYCODE_0)
in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> "F" + (keyCode - KeyEvent.KEYCODE_F1 + 1)
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> "Enter"
KeyEvent.KEYCODE_ESCAPE -> "Escape"
KeyEvent.KEYCODE_DEL -> "Backspace"
KeyEvent.KEYCODE_FORWARD_DEL -> "Delete"
KeyEvent.KEYCODE_TAB -> "Tab"
KeyEvent.KEYCODE_SPACE -> "Space"
KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp"
KeyEvent.KEYCODE_DPAD_DOWN -> "ArrowDown"
KeyEvent.KEYCODE_DPAD_LEFT -> "ArrowLeft"
KeyEvent.KEYCODE_DPAD_RIGHT -> "ArrowRight"
KeyEvent.KEYCODE_CTRL_LEFT -> "ControlLeft"
KeyEvent.KEYCODE_CTRL_RIGHT -> "ControlRight"
KeyEvent.KEYCODE_META_LEFT -> "MetaLeft"
KeyEvent.KEYCODE_META_RIGHT -> "MetaRight"
KeyEvent.KEYCODE_SHIFT_LEFT -> "ShiftLeft"
KeyEvent.KEYCODE_SHIFT_RIGHT -> "ShiftRight"
KeyEvent.KEYCODE_ALT_LEFT -> "AltLeft"
KeyEvent.KEYCODE_ALT_RIGHT -> "AltRight"
KeyEvent.KEYCODE_COMMA -> "Comma"
KeyEvent.KEYCODE_PERIOD -> "Period"
KeyEvent.KEYCODE_MINUS -> "Minus"
KeyEvent.KEYCODE_EQUALS -> "Equal"
KeyEvent.KEYCODE_LEFT_BRACKET -> "BracketLeft"
KeyEvent.KEYCODE_RIGHT_BRACKET -> "BracketRight"
KeyEvent.KEYCODE_BACKSLASH -> "Backslash"
KeyEvent.KEYCODE_SEMICOLON -> "Semicolon"
KeyEvent.KEYCODE_APOSTROPHE -> "Quote"
KeyEvent.KEYCODE_GRAVE -> "Backquote"
KeyEvent.KEYCODE_SLASH -> "Slash"
else -> ""
}
}
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val editable = (window.currentFocus as? EditText)?.text
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
}
}
class PaseoHardwareKeyboardModule : Module(), ExtraWindowEventListener, InputManager.InputDeviceListener, ComponentCallbacks {
private var inputManager: InputManager? = null
private var registeredReactContext: ReactContext? = null
private fun keyboardPresent(): Boolean {
val config = registeredReactContext?.resources?.configuration
return (config?.keyboard == Configuration.KEYBOARD_QWERTY && config.hardKeyboardHidden != Configuration.HARDKEYBOARDHIDDEN_YES) ||
InputDevice.getDeviceIds().any { id -> InputDevice.getDevice(id)?.let { !it.isVirtual && it.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC } == true }
}
private fun presenceChanged() { sendEvent("onHardwareKeyboardPresenceChanged", emptyMap<String, Any>()) }
override fun onInputDeviceAdded(id: Int) { presenceChanged() }
override fun onInputDeviceRemoved(id: Int) { presenceChanged() }
override fun onInputDeviceChanged(id: Int) { presenceChanged() }
override fun onConfigurationChanged(config: Configuration) { presenceChanged() }
override fun onLowMemory() {}
override fun onExtraWindowCreate(window: Window) { HardwareKeyboard.observe(window) }
override fun onExtraWindowDestroy(window: Window) { HardwareKeyboard.release(window) }
override fun definition() = ModuleDefinition {
Name("PaseoHardwareKeyboard")
Events("onHardwareKeyboardShortcut", "onHardwareKeyboardPresenceChanged")
OnCreate {
HardwareKeyboard.module = this@PaseoHardwareKeyboardModule
val context = appContext.reactContext
require(context is ReactContext) { "Hardware keyboard observation requires a React context" }
registeredReactContext = context
context.registerComponentCallbacks(this@PaseoHardwareKeyboardModule)
inputManager = context.getSystemService(InputManager::class.java)
inputManager?.registerInputDeviceListener(this@PaseoHardwareKeyboardModule, Handler(Looper.getMainLooper()))
context.addExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
UiThreadUtil.runOnUiThread { appContext.currentActivity?.window?.let { HardwareKeyboard.observe(it) } }
}
OnDestroy {
inputManager?.unregisterInputDeviceListener(this@PaseoHardwareKeyboardModule)
inputManager = null
registeredReactContext?.unregisterComponentCallbacks(this@PaseoHardwareKeyboardModule)
registeredReactContext?.removeExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
registeredReactContext = null
if (HardwareKeyboard.module === this@PaseoHardwareKeyboardModule) {
HardwareKeyboard.module = null
HardwareKeyboard.overlayKeys = emptySet()
UiThreadUtil.runOnUiThread { if (HardwareKeyboard.module == null) HardwareKeyboard.releaseAll() }
}
}
Function("getHardwareKeyboardPresent") { keyboardPresent() }
Function("setOverlayKeys") { keys: List<String> ->
HardwareKeyboard.overlayKeys = keys.filter { it in setOf("Escape", "Enter", "ArrowUp", "ArrowDown") }.toSet()
}
}
internal fun emit(event: Map<String, Any>) { sendEvent("onHardwareKeyboardShortcut", event) }
}
@@ -0,0 +1,17 @@
package sh.paseo.keyboard
import android.app.Activity
import android.content.Context
import android.os.Bundle
import expo.modules.core.interfaces.Package
import expo.modules.core.interfaces.ReactActivityLifecycleListener
class PaseoHardwareKeyboardPackage : Package {
override fun createReactActivityLifecycleListeners(activityContext: Context): List<ReactActivityLifecycleListener> = listOf(
object : ReactActivityLifecycleListener {
override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { HardwareKeyboard.observe(activity.window) }
override fun onResume(activity: Activity) { HardwareKeyboard.observe(activity.window) }
override fun onDestroy(activity: Activity) { HardwareKeyboard.release(activity.window) }
}
)
}
@@ -0,0 +1,11 @@
package sh.paseo.keyboard
// A modal can forward the same physical event into the activity window. Keep
// observation outside that nested delivery, without retaining recycled KeyEvents.
internal class WindowKeyDispatcher<Event : Any> {
private val delivering = java.util.Collections.newSetFromMap(java.util.IdentityHashMap<Event, Boolean>())
fun dispatch(event: Event, observe: () -> Boolean, deliver: () -> Boolean): Boolean {
if (!delivering.add(event)) return deliver()
return try { observe() || deliver() } finally { delivering.remove(event) }
}
}
@@ -0,0 +1,11 @@
package sh.paseo.keyboard
import org.junit.Assert.assertEquals
import org.junit.Test
class HardwareCharacterKeyTest {
@Test fun deadAccentUsesDomKeyWithoutThrowing() {
assertEquals("Dead", hardwareCharacterKey(Int.MIN_VALUE or 0xB4))
}
@Test fun ordinaryUnicodeRemainsText() {
assertEquals("é", hardwareCharacterKey(0xE9))
}
}
@@ -0,0 +1,27 @@
package sh.paseo.keyboard
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WindowKeyDispatcherTest {
@Test fun observesOnePressAcrossNestedWindows() {
val dispatcher = WindowKeyDispatcher<Any>()
val event = Any()
var observations = 0
var deliveries = 0
dispatcher.dispatch(event, { observations++; false }, {
dispatcher.dispatch(event, { observations++; false }, { deliveries++; false })
})
assertEquals(1, observations)
assertEquals(1, deliveries)
dispatcher.dispatch(event, { observations++; false }, { false })
assertEquals(2, observations)
}
@Test fun consumedOverlayKeyDoesNotReachTheView() {
val dispatcher = WindowKeyDispatcher<Any>()
var deliveries = 0
assertTrue(dispatcher.dispatch(Any(), { true }, { deliveries++; false }))
assertEquals(0, deliveries)
}
}
@@ -0,0 +1,29 @@
const { withAppDelegate } = require("expo/config-plugins");
const SCENE_START = "super.scene(scene, willConnectTo: session, options: connectionOptions)";
function configureHardwareKeyboardAppDelegate(source) {
source = source.replace(
/^import PaseoHardwareKeyboard$/m,
"internal import PaseoHardwareKeyboard",
);
if (source.includes("PaseoHardwareKeyboardWindow.observing(window)")) return source;
if (!source.includes(SCENE_START)) {
throw new Error("Hardware keyboard observation requires the app's Expo scene delegate");
}
const installation = `${SCENE_START}
if let window {
let observedWindow = PaseoHardwareKeyboardWindow.observing(window)
self.window = observedWindow
(UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider)?.window = observedWindow
}`;
return "internal import PaseoHardwareKeyboard\n" + source.replace(SCENE_START, installation);
}
module.exports = function withHardwareKeyboardWindow(config) {
return withAppDelegate(config, (modConfig) => {
modConfig.modResults.contents = configureHardwareKeyboardAppDelegate(
modConfig.modResults.contents,
);
return modConfig;
});
};
module.exports.configureHardwareKeyboardAppDelegate = configureHardwareKeyboardAppDelegate;
@@ -1,7 +1,9 @@
{
"platforms": ["ios"],
"platforms": ["ios", "android"],
"ios": {
"modules": ["PaseoHardwareKeyboardModule"],
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
"modules": ["PaseoHardwareKeyboardModule"]
},
"android": {
"modules": ["sh.paseo.keyboard.PaseoHardwareKeyboardModule"]
}
}
@@ -1,53 +1,159 @@
import ExpoModulesCore
import UIKit
import GameController
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
private weak var activeModule: PaseoHardwareKeyboardModule?
private var isHardwareSubmitEnabled = false
private var overlayKeys: Set<String> = []
@objc
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
public override func createRootViewController() -> UIViewController? {
return PaseoHardwareKeyboardRootViewController()
/// UIKit spells non-character keys as sentinel input strings rather than as the
/// character they would type. JS sends the DOM `code` and the mapping lives
/// here, so the constants stay in the one place that owns UIKit vocabulary.
private let namedKeyInputs: [String: String] = [
"Escape": UIKeyCommand.inputEscape,
"ArrowUp": UIKeyCommand.inputUpArrow,
"ArrowDown": UIKeyCommand.inputDownArrow,
"Enter": "\r",
]
/// Whether the first responder is mid-IME-composition.
///
/// A committing key has to stay out of the way while a CJK candidate is being
/// chosen: Enter confirms the candidate, and consuming it as a key command
/// would submit the half-composed text instead.
private func isComposingText() -> Bool {
guard let responder = UIResponder.paseoCurrentFirstResponder as? UITextInput else {
return false
}
return responder.markedTextRange != nil
}
public class PaseoHardwareKeyboardModule: Module {
private var keyboardObservers: [NSObjectProtocol] = []
public func definition() -> ModuleDefinition {
Name("PaseoHardwareKeyboard")
Events(hardwareSubmitEventName)
Events(hardwareSubmitEventName, hardwareShortcutEventName, "onHardwareKeyboardPresenceChanged")
OnCreate {
activeModule = self
for name in [Notification.Name.GCKeyboardDidConnect, Notification.Name.GCKeyboardDidDisconnect] {
self.keyboardObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
self?.sendEvent("onHardwareKeyboardPresenceChanged", [:])
})
}
}
Function("getHardwareKeyboardPresent") { GCKeyboard.coalesced != nil }
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
DispatchQueue.main.async {
isHardwareSubmitEnabled = enabled
}
}
Function("setOverlayKeys") { (keys: [String]) in
DispatchQueue.main.async {
overlayKeys = Set(keys).intersection(["Enter", "ArrowUp", "ArrowDown"])
UIMenuSystem.main.setNeedsRebuild()
}
}
OnDestroy {
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
self.keyboardObservers = []
if activeModule === self {
activeModule = nil
isHardwareSubmitEnabled = false
overlayKeys = []
}
isHardwareSubmitEnabled = false
}
}
fileprivate func emitHardwareKeyboardSubmit() {
sendEvent(hardwareSubmitEventName, [:])
}
fileprivate func emitHardwareKeyboardPress(_ press: UIPress, isRepeat: Bool) {
guard let key = press.key else { return }
let code = hardwareKeyCode(key)
let characters = key.characters
let named = namedKeyInputs.first { $0.value == characters }?.key
let keyName: String
if code.hasPrefix("Meta") { keyName = "Meta" }
else if code.hasPrefix("Control") { keyName = "Control" }
else if code.hasPrefix("Shift") { keyName = "Shift" }
else if code.hasPrefix("Alt") { keyName = "Alt" }
else { keyName = named ?? (characters.isEmpty ? code : characters) }
guard !code.isEmpty else { return }
sendEvent(hardwareShortcutEventName, [
"key": keyName,
"code": code,
"altKey": key.modifierFlags.contains(.alternate),
"ctrlKey": key.modifierFlags.contains(.control),
"metaKey": key.modifierFlags.contains(.command),
"shiftKey": key.modifierFlags.contains(.shift),
"repeat": isRepeat,
"isComposing": isComposingText(),
"phase": press.phase == .ended || press.phase == .cancelled ? "up" : "down",
])
}
}
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
override var keyCommands: [UIKeyCommand]? {
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
return super.keyCommands
}
public final class PaseoHardwareKeyboardWindow: UIWindow {
private var pressedCodes: Set<Int> = []
public static func observing(_ original: UIWindow) -> UIWindow {
if original is PaseoHardwareKeyboardWindow { return original }
guard let scene = original.windowScene else { return original }
let observed = PaseoHardwareKeyboardWindow(windowScene: scene)
observed.windowLevel = original.windowLevel
observed.backgroundColor = original.backgroundColor
let controller = original.rootViewController
original.rootViewController = nil
original.isHidden = true
observed.rootViewController = controller
observed.makeKeyAndVisible()
return observed
}
public override func sendEvent(_ event: UIEvent) {
if let event = event as? UIPressesEvent {
for press in event.allPresses where press.phase == .began || press.phase == .ended || press.phase == .cancelled {
guard let key = press.key else { continue }
let raw = Int(key.keyCode.rawValue)
let isRepeat = press.phase == .began && pressedCodes.contains(raw)
if press.phase == .began { pressedCodes.insert(raw) }
else { pressedCodes.remove(raw) }
activeModule?.emitHardwareKeyboardPress(press, isRepeat: isRepeat)
}
}
super.sendEvent(event)
}
public override var keyCommands: [UIKeyCommand]? {
var commands = super.keyCommands ?? []
if isHardwareSubmitEnabled && !isPlainReturnClaimed && !isComposingText()
&& UIDevice.current.userInterfaceIdiom == .pad
{
commands.append(makeSubmitCommand())
}
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
return commands
}
/// Whether something registered has already asked for an unmodified Return.
///
/// The submit command is registered for as long as the composer holds focus,
/// which includes while a picker is open over it. Registering both would put
/// two commands on one press, and the composer would win a press the picker
/// asked for — Enter would create the workspace instead of choosing a project.
private var isPlainReturnClaimed: Bool {
overlayKeys.contains("Enter")
}
private func makeSubmitCommand() -> UIKeyCommand {
let command = UIKeyCommand(
input: "\r",
modifierFlags: [],
@@ -56,7 +162,14 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
if #available(iOS 15.0, *) {
command.wantsPriorityOverSystemBehavior = true
}
return (super.keyCommands ?? []) + [command]
return command
}
private func makeShortcutCommand(_ key: String) -> UIKeyCommand? {
guard let input = namedKeyInputs[key] else { return nil }
let command = UIKeyCommand(input: input, modifierFlags: [], action: #selector(handleHardwareKeyboardShortcut(_:)))
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
return command
}
@objc
@@ -67,6 +180,9 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
activeModule?.emitHardwareKeyboardSubmit()
}
@objc
private func handleHardwareKeyboardShortcut(_ sender: UIKeyCommand) {}
private func canSubmitCurrentTextInput() -> Bool {
guard let responder = UIResponder.paseoCurrentFirstResponder else {
return false
@@ -97,3 +213,25 @@ private extension UIResponder {
UIResponder.currentFirstResponder = self
}
}
private func hardwareKeyCode(_ key: UIKey) -> String {
let raw = key.keyCode.rawValue
if raw >= 4 && raw <= 29 {
let letter = UnicodeScalar(Int(raw) + 61)!
return "Key" + String(letter)
}
if raw >= 30 && raw <= 38 { return "Digit" + String(raw - 29) }
if raw == 39 { return "Digit0" }
let codes: [Int: String] = [
40: "Enter", 41: "Escape", 42: "Backspace", 43: "Tab", 44: "Space",
45: "Minus", 46: "Equal", 47: "BracketLeft", 48: "BracketRight",
49: "Backslash", 51: "Semicolon", 52: "Quote", 53: "Backquote",
54: "Comma", 55: "Period", 56: "Slash", 76: "Delete", 79: "ArrowRight",
80: "ArrowLeft", 81: "ArrowDown", 82: "ArrowUp", 224: "ControlLeft",
225: "ShiftLeft", 226: "AltLeft", 227: "MetaLeft", 228: "ControlRight",
229: "ShiftRight", 230: "AltRight", 231: "MetaRight",
]
if raw >= 58 && raw <= 69 { return "F" + String(raw - 57) }
return codes[Int(raw)] ?? ""
}
@@ -4,11 +4,13 @@ import android.graphics.Rect
import android.text.Selection
import android.text.Spannable
import android.view.View
import android.view.InputDevice
import android.view.MotionEvent
import android.os.SystemClock
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.BridgeReactContext
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
@@ -24,6 +26,40 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ScrollSelectionTest {
@Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before)
assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp)
}
}
@Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY < before)
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY > afterUp)
}
}
@Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ ->
scroll.setScrollEnabled(false)
val before = scroll.scrollY
withWheel(1f) { scroll.dispatchGenericMotionEvent(it) }
assertEquals(before, scroll.scrollY)
}
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
val before = scroll.scrollY
assertTrue(text.requestFocus())
@@ -70,7 +106,7 @@ class ScrollSelectionTest {
val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = BridgeReactContext(appContext)
val reactContext = ScrollTestContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
@@ -98,4 +134,26 @@ class ScrollSelectionTest {
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) {
val time = SystemClock.uptimeMillis()
val properties = MotionEvent.PointerProperties().apply {
id = 0
toolType = MotionEvent.TOOL_TYPE_MOUSE
}
val coordinates = MotionEvent.PointerCoords().apply {
x = 150f
y = 150f
setAxisValue(MotionEvent.AXIS_VSCROLL, axis)
}
val event = MotionEvent.obtain(
time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates),
0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0,
)
try {
check(event)
} finally {
event.recycle()
}
}
}
@@ -0,0 +1,19 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -2,6 +2,8 @@ package sh.paseo.scroll
import android.graphics.Matrix
import android.graphics.Rect
import android.view.InputDevice
import android.view.MotionEvent
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule
@@ -30,13 +32,47 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
private val transformValues = FloatArray(9)
private fun isVerticallyInverted(): Boolean {
// Android FlatList uses scale: -1, which RN decomposes into a 180-degree
// rotation with positive scaleY. Read the resulting transform, not scaleY.
matrix.getValues(transformValues)
return transformValues[Matrix.MSCALE_Y] < 0
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL ||
!event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
return super.onGenericMotionEvent(event)
}
if (!scrollEnabled) return false
// Android applies wheel axes directly to scrollY; the inversion transform only
// transforms pointer coordinates. Reverse the vertical axis before delegating so
// platform scroll bounds and scroll events still apply.
val properties = Array(event.pointerCount) { index ->
MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) }
}
val coordinates = Array(event.pointerCount) { index ->
MotionEvent.PointerCoords().also {
event.getPointerCoords(index, it)
it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
}
val correctedEvent = MotionEvent.obtain(
event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates,
event.metaState, event.buttonState, event.xPrecision, event.yPrecision,
event.deviceId, event.edgeFlags, event.source, event.flags,
)
return try {
super.onGenericMotionEvent(correctedEvent)
} finally {
correctedEvent.recycle()
}
}
// Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
matrix.getValues(transformValues)
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.2",
"version": "0.15.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -40,7 +40,7 @@
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :paseo-hardware-keyboard:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -15,7 +15,7 @@ import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } f
import { Button } from "@/components/ui/button";
import { usePaneFocus } from "@/panels/pane-context";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { hasActiveOverlay } from "@/keyboard";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
@@ -104,7 +104,7 @@ export function ChatFind({
useEffect(() => {
if (!active || !isInteractive) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
if (event.defaultPrevented || hasActiveOverlay() || isImeComposingKeyboardEvent(event))
return;
if (isFindShortcut(event, findShortcutPlatform())) {
event.preventDefault();
@@ -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");
},
};
}
+20 -44
View File
@@ -1,3 +1,5 @@
import { useHardwareKeyboardPresent } from "@/keyboard";
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,7 +37,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, useIsSetupDecisionPending } from "@/onboarding";
import { OnboardingHost } from "@/onboarding";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
@@ -89,7 +91,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useKeyboardShortcuts } from "@/keyboard/use-shortcuts";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
@@ -138,7 +140,6 @@ export interface HostRuntimeBootstrapState {
splashError: string | null;
retry: () => void;
hasGivenUpWaitingForHost: boolean;
isWaitingForSetup: boolean;
storeReady: boolean;
startupBlocker: StartupBlocker;
}
@@ -147,7 +148,6 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
splashError: null,
retry: () => {},
hasGivenUpWaitingForHost: false,
isWaitingForSetup: false,
storeReady: false,
startupBlocker: { kind: "none" },
});
@@ -244,9 +244,6 @@ function useDaemonStartIsRunning(): boolean {
}
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
// How long the splash waits on top of host startup to learn whether setup opens. A slow host
// lands on the home instead, and setup opens over it once it is decided.
const STARTUP_SETUP_WAIT_TIMEOUT_MS = 3_000;
async function shouldStartBuiltInDaemon(): Promise<boolean> {
if (!shouldUseDesktopDaemon()) {
@@ -308,23 +305,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
};
}, [shouldRunGiveUpTimer]);
const isSetupDecisionPending = useIsSetupDecisionPending();
const [hasGivenUpWaitingForSetup, setHasGivenUpWaitingForSetup] = useState(false);
const isWaitingForSetup = isSetupDecisionPending && !hasGivenUpWaitingForSetup;
const shouldRunSetupWaitTimer = isWaitingForSetup && startupBlocker.kind === "none";
useEffect(() => {
if (!shouldRunSetupWaitTimer) {
return;
}
const handle = setTimeout(() => {
setHasGivenUpWaitingForSetup(true);
}, STARTUP_SETUP_WAIT_TIMEOUT_MS);
return () => {
clearTimeout(handle);
};
}, [shouldRunSetupWaitTimer]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
@@ -335,15 +315,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const storeReady = resolveStartupNavigationReady({ startupBlocker });
const state = useMemo<HostRuntimeBootstrapState>(
() => ({
splashError,
retry,
hasGivenUpWaitingForHost,
isWaitingForSetup,
storeReady,
startupBlocker,
}),
[splashError, retry, hasGivenUpWaitingForHost, isWaitingForSetup, storeReady, startupBlocker],
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
);
return (
@@ -495,6 +468,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<AddHostFlowHost />
<AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
@@ -524,6 +498,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<OnboardingHost />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
@@ -534,10 +509,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<ChangelogHost />
<QuittingOverlay />
</AppearanceStyleBoundary>
{/* Setup changes the theme while it is open, so it stays out of the remount above. Its
colors are CSS variables and themed props, which update in place, and setup changes no
font size. */}
<OnboardingHost />
</View>
);
@@ -579,8 +550,12 @@ function SidebarChrome({
const isMobileActive = useIsMobilePanelActive("agent-list");
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
return (
<SidebarModelProvider active={active}>
<SidebarModelProvider
active={active}
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
>
{mounted ? <LeftSidebar active={active} /> : null}
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
</SidebarModelProvider>
@@ -839,7 +814,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" />
@@ -924,11 +898,13 @@ function RootAppTree() {
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
<AddHostProvider>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</AddHostProvider>
</LucideProvider>
</View>
</GestureHandlerRootView>
+2 -10
View File
@@ -45,19 +45,11 @@ export default function Index() {
}),
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
isWaitingForSetup: bootstrapState.isWaitingForSetup,
});
const splash = <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
if (startupRoute.kind === "redirect") {
// The loading screen stays up until the target route paints; a redirect alone renders nothing.
return (
<>
{splash}
<Redirect href={startupRoute.href} />
</>
);
return <Redirect href={startupRoute.href} />;
}
return splash;
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
+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} />;
}
@@ -1,3 +1,4 @@
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { Modal } from "@/components/ui/modal";
import equal from "fast-deep-equal";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
@@ -52,11 +53,7 @@ import { useProjects } from "@/hooks/use-projects";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { createProjectIconTarget } from "@/projects/icon-target";
import { useProjectIcons } from "@/projects/icons";
import {
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import {
useKeyboardShortcutsStore,
@@ -763,6 +760,10 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
);
}
// Escape arrives without asking; it is registered for `agent.interrupt` anyway.
// Backspace stays off deliberately — the palette pops its scope with it on web,
// but a registered command would stop the search field deleting text.
export function CommandCenter() {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
@@ -780,7 +781,7 @@ export function CommandCenter() {
interaction,
);
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const modalLayer = useGlobalOverlayLayer("modal", state.open);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: state.open,
isEnabled: showBottomSheet,
@@ -874,23 +875,20 @@ export function CommandCenter() {
: {}),
scrollEventThrottle: 16,
};
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
[state],
);
const submit = useCallback(() => state.key("Enter"), [state]);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (!state.key(event.key)) return false;
event.preventDefault();
return true;
},
[state],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && state.open && !showBottomSheet,
const setOverlayScope = useOverlayRegistration({
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
active: state.open,
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
onKeyDown: handleOverlayKeyDown,
});
if (showBottomSheet) {
@@ -911,45 +909,46 @@ export function CommandCenter() {
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
initialValue={state.query}
variant="bottom-sheet"
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
<OverlayLayerProvider layer={modalLayer}>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
initialValue={state.query}
variant="bottom-sheet"
onChangeText={state.setQuery}
onSubmitEditing={submit}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</OverlayLayerProvider>
</IsolatedBottomSheetModal>
);
}
if (!state.open) return null;
return (
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
<OverlayLayerProvider layer={modalLayer}>
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
<View ref={setOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
@@ -1,3 +1,4 @@
import { usesDesktopBindings } from "@/keyboard";
import { useMemo } from "react";
import { router, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
@@ -15,12 +16,12 @@ import {
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { usePanelStore } from "@/stores/panel-store";
@@ -111,7 +112,7 @@ export function CommandCenterRootActions() {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const shortcutsAvailable = useHardwareKeyboardPresent();
const openAddProject = useOpenAddProject();
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
@@ -128,7 +129,7 @@ export function CommandCenterRootActions() {
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList;
const shortcutPlatform = useMemo(
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
() => ({ isMac: getShortcutOs() === "mac", usesDesktopBindings }),
[],
);
const actions = useMemo<CommandCenterContribution[]>(() => {
@@ -1,3 +1,4 @@
import { usesDesktopBindings } from "@/keyboard";
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -105,7 +106,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
}
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
const platform = { isMac: getShortcutOs() === "mac", usesDesktopBindings };
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
}
@@ -6,7 +6,7 @@ describe("resolveWorkspaceCommandCenterShortcuts", () => {
expect(
resolveWorkspaceCommandCenterShortcuts({
overrides: {},
platform: { isMac: true, isDesktop: true },
platform: { isMac: true, usesDesktopBindings: true },
}).newAgent,
).toEqual([["mod", "shift", "A"]]);
});
@@ -6,7 +6,7 @@ import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions"
interface ResolveWorkspaceCommandCenterShortcutsInput {
overrides: ShortcutOverrides;
platform: { isMac: boolean; isDesktop: boolean };
platform: { isMac: boolean; usesDesktopBindings: boolean };
}
export function resolveWorkspaceCommandCenterShortcuts({
@@ -1,3 +1,4 @@
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createPortal } from "react-dom";
@@ -6,12 +7,7 @@ import { Keyboard, Pressable, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
getOverlayRoot,
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import { getOverlayRoot, OverlayLayerProvider, useGlobalOverlayLayer } from "../lib/overlay-root";
import {
BottomSheetHandle,
useBottomSheetGestureHandlers,
@@ -236,28 +232,6 @@ const styles = StyleSheet.create((theme) => ({
}));
const WEB_EXIT_DURATION_MS = 160;
const WEB_ENTER_DURATION_MS = 160;
const WEB_ENTER_ANIMATION_NAME = "paseo-modal-enter";
// The desktop card fades in; the dim lands at once, so the screen behind is never seen undimmed
// while the dialog arrives. A CSS animation runs from the card's first paint, so a dialog that opens
// on its own, like first-run setup, never pops in. Closing fades the whole overlay. With reduced
// motion the keyframes are never defined, so the card appears at once.
function ensureWebEnterKeyframes() {
if (!isWeb || typeof document === "undefined") return;
if (document.getElementById(WEB_ENTER_ANIMATION_NAME)) return;
const style = document.createElement("style");
style.id = WEB_ENTER_ANIMATION_NAME;
style.textContent = `@media (prefers-reduced-motion: no-preference) { @keyframes ${WEB_ENTER_ANIMATION_NAME} { from { opacity: 0; } to { opacity: 1; } } }`;
document.head.appendChild(style);
}
ensureWebEnterKeyframes();
// The CSS animation props are not part of ViewStyle without the generated expo-env.d.ts.
const WEB_ENTER_STYLE = {
animationName: WEB_ENTER_ANIMATION_NAME,
animationDuration: `${WEB_ENTER_DURATION_MS}ms`,
animationTimingFunction: "ease-out",
} as unknown as ViewStyle;
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
@@ -677,7 +651,7 @@ export function AdaptiveModalSheet({
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
const [isWebClosing, setIsWebClosing] = useState(false);
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
const modalLayer = useGlobalOverlayLayer("modal", visible || shouldRenderWeb);
const handleDismiss = useCallback(() => {
handleSheetDismiss();
onDismiss?.();
@@ -688,7 +662,6 @@ export function AdaptiveModalSheet({
styles.desktopCard,
desktopHeight != null && { height: desktopHeight },
desktopMaxWidth != null && { maxWidth: desktopMaxWidth },
isWeb && WEB_ENTER_STYLE,
],
[desktopMaxWidth, desktopHeight],
);
@@ -706,8 +679,8 @@ export function AdaptiveModalSheet({
[isWebClosing, modalLayer],
);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (event.key !== "Escape") return false;
event.preventDefault();
event.stopPropagation();
@@ -716,10 +689,11 @@ export function AdaptiveModalSheet({
},
[onClose],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && !isMobile && visible,
const setOverlayScope = useOverlayRegistration({
keys: ["Escape"],
active: visible,
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
onKeyDown: handleOverlayKeyDown,
});
useEffect(() => {
@@ -758,18 +732,20 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
<CompactSheetBody
header={header}
onClose={onClose}
testID={testID}
footer={footerView}
isKeyboardVisible={isKeyboardVisible}
scrollable={scrollable}
bodyStyle={bodyStyle}
contentStyle={contentStyle}
>
{children}
</CompactSheetBody>
<OverlayLayerProvider layer={modalLayer}>
<CompactSheetBody
header={header}
onClose={onClose}
testID={testID}
footer={footerView}
isKeyboardVisible={isKeyboardVisible}
scrollable={scrollable}
bodyStyle={bodyStyle}
contentStyle={contentStyle}
>
{children}
</CompactSheetBody>
</OverlayLayerProvider>
</IsolatedBottomSheetModal>
);
}
@@ -813,13 +789,7 @@ export function AdaptiveModalSheet({
style={ABSOLUTE_FILL_STYLE}
onPress={onClose}
/>
<View
ref={setWebOverlayScope}
style={desktopCardStyle}
role="dialog"
aria-modal
tabIndex={-1}
>
<View ref={setOverlayScope} style={desktopCardStyle} role="dialog" aria-modal tabIndex={-1}>
{cardInner}
</View>
</View>
@@ -1,3 +1,5 @@
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -68,19 +70,15 @@ import {
} from "@/components/project-picker-options";
import { Shortcut } from "@/components/ui/shortcut";
import { useRevealActiveItem } from "@/components/ui/scroll-reveal";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { getIsElectronRuntime } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { isWeb } from "@/constants/platform";
import { pickDirectory } from "@/desktop/pick-directory";
import { useFetchQuery } from "@/data/query";
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project";
import {
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
import {
useHosts,
useHostRuntimeClient,
@@ -92,7 +90,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;
@@ -301,7 +299,7 @@ function FlowRow({
}
function FlowHint({ keys, action }: { keys: string[]; action: string }) {
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const shortcutsAvailable = useHardwareKeyboardPresent();
if (!shortcutsAvailable) return null;
return (
@@ -325,6 +323,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);
@@ -458,13 +457,10 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
});
const handleBack = useCallback(() => {
setState((current) => {
const previous = backAddProjectPage(current);
if (previous) return previous;
onClose();
return current;
});
}, [onClose]);
const previous = backAddProjectPage(state);
if (previous) setState(previous);
else onClose();
}, [onClose, state]);
const openNewWorkspaceForProject = useCallback(
(serverId: string, project: WorkspaceProjectDescriptorPayload) => {
@@ -616,9 +612,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 +713,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
directoryPaths,
githubQuery.data,
host,
addHost,
onClose,
openAddedProject,
page,
@@ -812,30 +809,22 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
[activeIndex, handleBack, rows, submitActive],
);
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
const modalLayer = useGlobalOverlayLayer("modal", true);
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (!handleKey(event.key)) return false;
event.preventDefault();
return true;
},
[handleKey],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb,
const setOverlayScope = useOverlayRegistration({
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
active: true,
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
onKeyDown: handleOverlayKeyDown,
});
const handleNativeKeyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
if (key === "ArrowDown" || key === "ArrowUp" || key === "Escape") {
handleKey(key);
}
},
[handleKey],
);
const handleInputChange = useCallback((value: string) => {
setState((current) =>
currentAddProjectPage(current).kind === "new-directory-name"
@@ -870,7 +859,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
<View style={styles.overlay} testID="add-project-flow">
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
<View
ref={setWebOverlayScope}
ref={setOverlayScope}
style={styles.panel}
testID={`add-project-flow-page-${page.kind}`}
accessibilityLabel={`Add project: ${page.kind}`}
@@ -889,29 +878,11 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
) : null}
</View>
</View>
{page.kind === "method" && isNative ? (
// Native hardware-keyboard events need a focused responder even without a visible field.
<TextInput
ref={inputRef}
onKeyPress={handleNativeKeyPress}
onSubmitEditing={submitActive}
showSoftInputOnFocus={false}
caretHidden
contextMenuHidden
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={styles.keyboardCapture}
testID="add-project-flow-keyboard-capture"
/>
) : null}
{page.kind !== "method" ? (
<ThemedTextInput
ref={inputRef}
initialValue={pageInput(page)}
onChangeText={handleInputChange}
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
onSubmitEditing={isWeb ? undefined : submitActive}
placeholder={pagePlaceholder(page)}
style={styles.input}
@@ -993,7 +964,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
</Modal>
);
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
return createElement(OverlayLayerProvider, { layer: modalLayer }, modal);
}
const styles = StyleSheet.create((theme) => ({
@@ -1067,12 +1038,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as object,
keyboardCapture: {
position: "absolute",
width: 1,
height: 1,
opacity: 0,
},
results: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
resultsContent: { paddingVertical: theme.spacing[2] },
row: {
@@ -1,3 +1,4 @@
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { Modal } from "@/components/ui/modal";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -8,12 +9,12 @@ import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { isNative, isWeb } from "@/constants/platform";
import { isNative } from "@/constants/platform";
import { SPACING } from "@/styles/theme";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
export type ImageLightboxSource =
| { type: "attachment"; metadata: AttachmentMetadata }
@@ -34,14 +35,14 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
const url = source?.type === "uri" ? source.uri : attachmentUrl;
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
const [errored, setErrored] = useState(false);
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null);
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
useEffect(() => {
setErrored(false);
}, [metadata?.id, url]);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (event.key !== "Escape") return false;
event.preventDefault();
event.stopPropagation();
@@ -50,10 +51,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
},
[onClose],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && source !== null,
const setOverlayScope = useOverlayRegistration({
keys: ["Escape"],
active: source !== null,
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
onKeyDown: handleOverlayKeyDown,
});
const actions = useMemo(
@@ -83,7 +85,7 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
image was opened (inside a sheet tab, the bottom one is already spent). */}
<SafeAreaProvider>
<WindowChromeRootRegion corners="both">
<View ref={setWebOverlayScope} style={styles.root}>
<View ref={setOverlayScope} style={styles.root}>
<Pressable
testID="attachment-lightbox-backdrop"
accessibilityRole="button"
@@ -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,8 @@
import { usesDesktopBindings } from "@/keyboard";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -24,14 +24,13 @@ export function KeyboardShortcutsDialog() {
const shortcutOs = getShortcutOs();
const isMac = shortcutOs === "mac";
const isDesktopApp = getIsElectronRuntime();
const { overrides } = useKeyboardShortcutOverrides();
// Effective bindings, so a shortcut the user unassigned lists no keys here
// instead of advertising a default that no longer fires.
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }, bindings),
[bindings, isDesktopApp, isMac],
() => buildKeyboardShortcutHelpSections({ isMac, usesDesktopBindings }, bindings),
[bindings, isMac],
);
const visibleSections = useMemo(
() => filterShortcutHelpSections({ sections, query, translate: t, shortcutOs }),
+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 {
@@ -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(
@@ -18,7 +18,7 @@ import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
@@ -79,7 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
export function SidebarHelpMenu() {
const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const shortcutsAvailable = useHardwareKeyboardPresent();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
const [open, setOpen] = useState(false);
@@ -50,13 +50,16 @@ const SidebarModelContext = createContext<SidebarModel | null>(null);
export function SidebarModelProvider({
active,
keyboardNavigationActive = false,
children,
}: {
active?: boolean;
keyboardNavigationActive?: boolean;
children: ReactNode;
}) {
const { t } = useTranslation();
const list = useSidebarWorkspacesList({ enabled: active });
const modelActive = active !== false || keyboardNavigationActive;
const list = useSidebarWorkspacesList({ enabled: modelActive });
const showBackground = useSidebarViewStore((state) => state.showBackground);
const groupMode = useSidebarViewStore((state) => state.groupMode);
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
@@ -98,10 +101,11 @@ export function SidebarModelProvider({
// anything; the label filter reads `labels`, which only exists on an entry. Hydration opens a
// live session-store subscription over every workspace on every visible host, so widening this
// for a filter that does not need it costs a retained-but-inactive sidebar real work.
// Hardware keyboard navigation needs the same filtered targets while the sidebar is hidden.
const needsWorkspaceEntries = groupMode !== "project" || hasActiveLabelFilter;
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
list.workspacePlacements,
active !== false || needsWorkspaceEntries,
modelActive || needsWorkspaceEntries,
);
const filteredWorkspaceEntriesByKey = useMemo(() => {
const byProject = filterWorkspacesByProjects({
@@ -84,6 +84,7 @@ import {
} from "./terminal-resize-debouncer";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useKeyboardFocusSurface } from "@/keyboard";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
@@ -458,6 +459,8 @@ export function TerminalPane({
}
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useKeyboardFocusSurface("terminal", isTerminalPresented && isPaneFocused && terminalId != null);
useEffect(() => {
const canRequest = canRequestFocusClaim({
isWorkspaceFocused: isTerminalPresented,
+64 -68
View File
@@ -1,3 +1,4 @@
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { Modal } from "@/components/ui/modal";
import {
useCallback,
@@ -62,12 +63,7 @@ import {
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import {
getOverlayRoot,
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
export { buildDesktopFrameStyle } from "./combobox-frame-style";
@@ -526,7 +522,6 @@ type DesktopKey = "ArrowDown" | "ArrowUp" | "Enter" | "Escape";
interface DesktopKeyHandlerInput {
isOpen: boolean;
isMobile: boolean;
orderedVisibleOptions: ComboboxOption[];
activeIndex: number;
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
@@ -871,11 +866,12 @@ function isDesktopKey(key: string): key is DesktopKey {
function dispatchDesktopKey(
input: DesktopKeyHandlerInput,
key: DesktopKey,
event?: KeyboardEvent,
event?: KeyboardInputEvent,
): boolean {
if (!input.isOpen) return false;
if (!IS_WEB && input.isMobile) return false;
// A native mobile bail used to live here because native had no key source, so
// nothing could call this. It has one now, and the sheet highlights an active
// row like the popover does, so a hardware keyboard drives both.
if (key === "ArrowDown" || key === "ArrowUp") {
event?.preventDefault();
handleDesktopArrowKey(input, key);
@@ -912,6 +908,7 @@ function resolveInitialActiveIndex(
}
interface MobileBodyProps {
overlayLayer: number;
isOpen: boolean;
snapPoints: string[];
handleIndicatorStyle: { backgroundColor: string };
@@ -990,45 +987,47 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
<ComboboxSheetFrame footer={props.footer}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</ComboboxSheetFrame>
<OverlayLayerProvider layer={props.overlayLayer}>
<ComboboxSheetFrame footer={props.footer}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</ComboboxSheetFrame>
</OverlayLayerProvider>
</IsolatedBottomSheetModal>
);
}
@@ -1062,7 +1061,8 @@ interface DesktopBodyProps {
overlayLayer: number;
isOpen: boolean;
handleClose: () => void;
handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean;
setOverlayScope: (node: unknown) => void;
handleDesktopKey: (key: DesktopKey, event?: KeyboardInputEvent) => boolean;
refs: ReturnType<typeof useFloating>["refs"];
shouldUseDesktopFade: boolean;
desktopFrameStyle: StyleProp<ViewStyle>;
@@ -1189,25 +1189,13 @@ function DesktopComboboxOptionsBody(props: {
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
const handleDesktopKey = props.handleDesktopKey;
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!isDesktopKey(event.key)) return false;
return handleDesktopKey(event.key, event);
},
[handleDesktopKey],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && props.isOpen,
layer: props.overlayLayer,
onKeyDown: handleWebOverlayKeyDown,
});
const setOverlayScope = props.setOverlayScope;
const setFloatingRef = useCallback(
(node: View | null) => {
props.refs.setFloating(node);
setWebOverlayScope(node);
setOverlayScope(node);
},
[props.refs, setWebOverlayScope],
[props.refs, setOverlayScope],
);
// Remove the portal before a retained chat can freeze its closing update.
@@ -1531,11 +1519,10 @@ export function Combobox({
);
const handleDesktopKey = useCallback(
(key: DesktopKey, event?: KeyboardEvent) => {
(key: DesktopKey, event?: KeyboardInputEvent) => {
return dispatchDesktopKey(
{
isOpen,
isMobile,
orderedVisibleOptions,
activeIndex,
setActiveIndex,
@@ -1546,7 +1533,7 @@ export function Combobox({
event,
);
},
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
[activeIndex, handleClose, handleSelect, isOpen, orderedVisibleOptions],
);
useDismissKeyboardOnOpen(isOpen, isMobile);
@@ -1583,12 +1570,20 @@ export function Combobox({
[],
);
const setOverlayScope = useOverlayRegistration({
active: isOpen,
layer: floatingLayer,
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
onKeyDown: (event) => (isDesktopKey(event.key) ? handleDesktopKey(event.key, event) : false),
});
const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder;
const hasChildren = Boolean(children);
if (isMobile) {
return (
<MobileComboboxBody
overlayLayer={floatingLayer}
isOpen={isOpen}
snapPoints={snapPoints}
handleIndicatorStyle={handleIndicatorStyle}
@@ -1628,6 +1623,7 @@ export function Combobox({
isOpen={isOpen}
handleClose={handleClose}
handleDesktopKey={handleDesktopKey}
setOverlayScope={setOverlayScope}
refs={refs}
shouldUseDesktopFade={shouldUseDesktopFade}
desktopFrameStyle={desktopFrameStyle}
@@ -1,3 +1,4 @@
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import {
createContext,
useCallback,
@@ -14,11 +15,7 @@ import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "rea
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { Portal } from "@gorhom/portal";
import {
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import { FloatingSurface } from "@/components/ui/floating";
import {
measureFloatingPanelPortalHost,
@@ -127,7 +124,7 @@ function WebHoverCard({
}, [clearGraceTimer, disabled]);
const keyPressed = useCallback(
(event: KeyboardEvent): boolean => {
(event: KeyboardInputEvent): boolean => {
if (!isWeb) return false;
const trigger = triggerRef.current as unknown as HTMLElement | null;
if (!trigger) return false;
@@ -139,7 +136,7 @@ function WebHoverCard({
return true;
} else if (
focusInside() &&
event.target === trigger &&
document.activeElement === trigger &&
["ArrowDown", "Enter", " "].includes(event.key)
) {
event.preventDefault();
@@ -158,7 +155,8 @@ function WebHoverCard({
[clearGraceTimer, focusInside, open, openNow],
);
const setOverlayScope = useWebOverlayRegistration({
const setOverlayScope = useOverlayRegistration({
keys: ["Escape"],
active: !disabled && open,
layer,
onKeyDown: keyPressed,
@@ -0,0 +1,4 @@
import type { KeyboardInputEvent } from "@/keyboard";
export function navigateMenuKeyboard(_event: KeyboardInputEvent): boolean {
return false;
}
@@ -0,0 +1,37 @@
import type { KeyboardInputEvent } from "@/keyboard";
export function navigateMenuKeyboard(event: KeyboardInputEvent): boolean {
const target = document.activeElement;
const surface = target?.closest<HTMLElement>('[data-menu-surface="true"]');
if (!surface) return false;
const items = Array.from(
surface.querySelectorAll<HTMLElement>(
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
),
);
if (items.length === 0) return false;
const currentIndex = items.findIndex((item) => item === document.activeElement);
let nextIndex: number | null = null;
if (event.key === "ArrowDown") {
if (currentIndex < 0) nextIndex = 0;
else nextIndex = (currentIndex + 1) % items.length;
}
if (event.key === "ArrowUp") {
if (currentIndex < 0) nextIndex = items.length - 1;
else nextIndex = (currentIndex - 1 + items.length) % items.length;
}
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = items.length - 1;
if (nextIndex !== null) {
event.preventDefault();
event.stopPropagation();
items[nextIndex]?.focus();
return true;
}
if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) {
event.preventDefault();
event.stopPropagation();
items[currentIndex]?.click();
return true;
}
return false;
}
@@ -1,3 +1,5 @@
import { navigateMenuKeyboard } from "./keyboard-navigation";
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { Modal } from "@/components/ui/modal";
import {
useCallback,
@@ -24,12 +26,7 @@ import { StyleSheet } from "react-native-unistyles";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import type { KeyboardFocusScope } from "@/keyboard/actions";
import {
getOverlayRoot,
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import {
computePosition,
getTransformOrigin,
@@ -405,8 +402,8 @@ export function MenuOverlay({
}): ReactElement | null {
const floatingLayer = useOverlayLayer("floating");
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
@@ -414,44 +411,15 @@ export function MenuOverlay({
return true;
}
const target = event.target instanceof Element ? event.target : null;
const surface = target?.closest<HTMLElement>('[data-menu-surface="true"]');
if (!surface) return false;
const items = Array.from(
surface.querySelectorAll<HTMLElement>(
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
),
);
if (items.length === 0) return false;
const currentIndex = items.findIndex((item) => item === document.activeElement);
let nextIndex: number | null = null;
if (event.key === "ArrowDown")
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
if (event.key === "ArrowUp")
nextIndex =
currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = items.length - 1;
if (nextIndex !== null) {
event.preventDefault();
event.stopPropagation();
items[nextIndex]?.focus();
return true;
}
if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) {
event.preventDefault();
event.stopPropagation();
items[currentIndex]?.click();
return true;
}
return false;
return navigateMenuKeyboard(event);
},
[onClose],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && visible,
const setOverlayScope = useOverlayRegistration({
keys: ["Escape"],
active: visible,
layer: floatingLayer,
onKeyDown: handleWebOverlayKeyDown,
onKeyDown: handleOverlayKeyDown,
restoreFocusRef,
});
@@ -463,7 +431,7 @@ export function MenuOverlay({
{...{
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
}}
ref={setWebOverlayScope}
ref={setOverlayScope}
collapsable={false}
style={[
styles.overlay,
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useMemo, type ReactElement } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { normalizeDisplayChord } from "@/components/ui/normalize-display-chord";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -17,7 +17,7 @@ export function Shortcut({
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}): ReactElement | null {
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const shortcutsAvailable = useHardwareKeyboardPresent();
const displayChord = normalizeDisplayChord(chord, keys);
const shortcutOs = getShortcutOs();
+7 -48
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
@@ -315,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>
);
@@ -125,6 +125,20 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
});
it("keeps keyboard navigation populated when the sidebar starts hidden", async () => {
await act(async () => {
root?.render(
<SidebarModelProvider active={false} keyboardNavigationActive>
<WorkspaceShortcutTargetsSubscriber enabled />
</SidebarModelProvider>,
);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "srv", workspaceId: "ws-1" },
{ serverId: "srv", workspaceId: "ws-2" },
]);
});
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
await act(async () => {
root?.render(
@@ -0,0 +1,28 @@
import React from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { dispatchTopOverlayKeyDown } from "@/keyboard";
import { useComposerFullscreenExit } from "./fullscreen-exit";
it("the native entry claims Escape until fullscreen closes", () => {
let exits = 0;
function Harness({ active }: { active: boolean }) {
useComposerFullscreenExit(active, () => exits++);
return null;
}
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
try {
flushSync(() => root.render(<Harness active />));
expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(true);
expect(exits).toBe(1);
flushSync(() => root.render(<Harness active={false} />));
expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(false);
expect(exits).toBe(1);
} finally {
root.unmount();
container.remove();
}
});
@@ -1,4 +1,6 @@
import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
import { useComposerHardwareBackExit } from "./hardware-back-exit";
import { useOverlayRegistration } from "@/keyboard";
import { useOverlayLayer } from "@/lib/overlay-root";
/**
* Escape leaves fullscreen before app shortcuts see it. The registration only claims
@@ -8,8 +10,10 @@ export function useComposerFullscreenExit(
active: boolean,
onExit: () => void,
): ((node: unknown) => void) | undefined {
useComposerHardwareBackExit(active, onExit);
const layer = useOverlayLayer("modal");
return useWebOverlayRegistration({
return useOverlayRegistration({
keys: ["Escape"],
active,
layer,
manageFocus: false,
@@ -2,10 +2,7 @@ import { useEffect } from "react";
import { BackHandler } from "react-native";
/** The hardware back button leaves fullscreen instead of the screen. */
export function useComposerFullscreenExit(
active: boolean,
onExit: () => void,
): ((node: unknown) => void) | undefined {
export function useComposerHardwareBackExit(active: boolean, onExit: () => void): void {
useEffect(() => {
if (!active) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
@@ -14,5 +11,4 @@ export function useComposerFullscreenExit(
});
return () => subscription.remove();
}, [active, onExit]);
return undefined;
}
@@ -0,0 +1 @@
export function useComposerHardwareBackExit(_active: boolean, _onExit: () => void): void {}
@@ -1,3 +1,4 @@
import { useKeyboardFocusSurface } from "@/keyboard";
import type { SendShortcut } from "@/hooks/use-settings";
import {
useComposerEditingSession,
@@ -1288,6 +1289,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
const focusInputKeys = useShortcutKeys("focus-message-input");
const [isInputFocused, setIsInputFocused] = useState(false);
useKeyboardFocusSurface("message-input", isInputFocused && isPaneFocused);
// Web text is DOM-owned between deferred draft publications. The action button only needs
// this boundary, so publish empty/non-empty transitions without rerendering for every key.
const initialHasLiveText = value.trim().length > 0;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
import type { BrowserKeyboardPolicy } from "@/desktop/browser/shortcuts";
import type { BrowserKeyboardPolicy } from "@/keyboard";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
type BrowserAutomationExecuteRequest = Extract<
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
import {
refreshExplorerDirectories,
restoreExpandedDirectories,
showHiddenFilesAndRestoreExpandedDirectories,
reconcileRestoredExpandedPaths,
} from "./tree";
import {
DIRECTORY_LISTING_INVALIDATED,
invalidateExplorerDirectoryListings,
loadExplorerDirectory,
} from "./directory-listings";
function directory(path: string, names: string[]): ExplorerDirectory {
return {
path,
entries: names.map((name) => ({
path: `${path}/${name}`,
name,
kind: "file",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
})),
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function explorer() {
let state: AgentFileExplorerState = {
listingGeneration: 0,
directories: new Map([[".", directory(".", ["docs"])]]),
isLoading: false,
lastError: null,
pendingRequest: null,
currentPath: ".",
history: ["."],
lastVisitedPath: ".",
selectedEntryPath: null,
};
const updateState = (update: (state: AgentFileExplorerState) => AgentFileExplorerState) => {
state = update(state);
return state;
};
return {
state: () => state,
load: (path: string, result: Promise<ExplorerDirectory>) =>
loadExplorerDirectory({
path,
updateState,
listDirectory: () => result,
fallbackError: "Directory listing failed",
options: { recordHistory: false, setCurrentPath: false },
}),
refresh: (retained: readonly string[]) =>
invalidateExplorerDirectoryListings(updateState, retained),
};
}
describe("directory listings across Refresh", () => {
it("ignores an earlier reply for a collapsed folder while the fresh root is loading", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh(["."]);
const fresh = deferred<ExplorerDirectory>();
const freshLoad = model.load(".", fresh.promise);
old.resolve(directory("docs", ["OLD.md"]));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().directories.has("docs")).toBe(false);
expect(model.state().isLoading).toBe(true);
expect(model.state().pendingRequest?.path).toBe(".");
const root = directory(".", ["docs", "NEW.md"]);
fresh.resolve(root);
await expect(freshLoad).resolves.toEqual(root);
expect(model.state().directories.get(".")).toEqual(root);
expect(model.state().isLoading).toBe(false);
});
it("does not let a pre-refresh reply replace a newer listing of the same folder", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh([".", "docs"]);
const fresh = directory("docs", ["NEW.md"]);
await model.load("docs", Promise.resolve(fresh));
old.resolve(directory("docs", ["OLD.md"]));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().directories.get("docs")).toEqual(fresh);
});
it("ignores errors from requests invalidated by Refresh", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh(["."]);
const fresh = deferred<ExplorerDirectory>();
const freshLoad = model.load(".", fresh.promise);
old.reject(new Error("Old request failed"));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().lastError).toBeNull();
expect(model.state().isLoading).toBe(true);
fresh.resolve(directory(".", ["docs"]));
await freshLoad;
});
it("keeps expanded listings visible and discards collapsed listings on Refresh", async () => {
const model = explorer();
const docs = directory("docs", ["guide.md"]);
await model.load("docs", Promise.resolve(docs));
await model.load("assets", Promise.resolve(directory("assets", ["logo.png"])));
model.refresh([".", "docs"]);
expect(model.state().directories.get("docs")).toEqual(docs);
expect(model.state().directories.has("assets")).toBe(false);
expect(model.state().currentPath).toBe(".");
expect(model.state().history).toEqual(["."]);
});
});
it.each([false, true])(
"preserves saved expansion when Refresh invalidates restoration (hidden: %s)",
async (hidden) => {
const model = explorer();
const folder = hidden ? ".hidden" : "docs";
const root: ExplorerDirectory = {
path: ".",
entries: [
{
name: folder,
path: folder,
kind: "directory",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
},
],
};
const saved = new Set([".", folder]);
const pending = deferred<ExplorerDirectory>();
const input = {
rootDirectory: root,
persistedExpandedPaths: saved,
requestDirectoryListing: (path: string) => model.load(path, pending.promise),
};
const restoration = hidden
? showHiddenFilesAndRestoreExpandedDirectories({ ...input, showHiddenFiles: () => {} })
: restoreExpandedDirectories({ ...input, showHiddenFiles: false });
const fresh = directory(folder, ["NEW.md"]);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: saved,
showHiddenFiles: hidden,
invalidateDirectoryListings: model.refresh,
requestDirectoryListing: (path) =>
model.load(path, Promise.resolve(path === "." ? root : fresh)),
});
pending.resolve(directory(folder, ["OLD.md"]));
const restored = await restoration;
const expanded = reconcileRestoredExpandedPaths({
persistedExpandedPaths: saved,
currentExpandedPaths: saved,
restoredExpandedPaths: restored,
});
expect(model.state().directories.get(folder)).toEqual(fresh);
expect(expanded).toEqual([".", folder]);
},
);
@@ -0,0 +1,91 @@
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
export const DIRECTORY_LISTING_INVALIDATED = Symbol("directory-listing-invalidated");
export type DirectoryListingResult =
| ExplorerDirectory
| null
| typeof DIRECTORY_LISTING_INVALIDATED;
type UpdateExplorerState = (
update: (state: AgentFileExplorerState) => AgentFileExplorerState,
) => AgentFileExplorerState | null;
function pushHistory(history: string[], path: string): string[] {
const normalizedHistory = history.length === 0 ? ["."] : history;
return normalizedHistory.at(-1) === path ? normalizedHistory : [...normalizedHistory, path];
}
export async function loadExplorerDirectory({
path,
options,
updateState,
listDirectory,
fallbackError,
}: {
path: string;
options?: { recordHistory?: boolean; setCurrentPath?: boolean };
updateState: UpdateExplorerState;
listDirectory: () => Promise<ExplorerDirectory>;
fallbackError: string;
}): Promise<DirectoryListingResult> {
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
const initial = updateState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path, mode: "list" },
...(shouldSetCurrentPath
? {
currentPath: path,
history: shouldRecordHistory ? pushHistory(state.history, path) : state.history,
lastVisitedPath: path,
}
: {}),
}));
if (!initial) return null;
const generation = initial.listingGeneration;
try {
const directory = await listDirectory();
let accepted = false;
updateState((state) => {
if (state.listingGeneration !== generation) return state;
accepted = true;
const directories = new Map(state.directories);
directories.set(directory.path, directory);
return { ...state, directories, isLoading: false, lastError: null, pendingRequest: null };
});
return accepted ? directory : DIRECTORY_LISTING_INVALIDATED;
} catch (error) {
let invalidated = false;
updateState((state) => {
if (state.listingGeneration !== generation) {
invalidated = true;
return state;
}
return {
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : fallbackError,
pendingRequest: null,
};
});
return invalidated ? DIRECTORY_LISTING_INVALIDATED : null;
}
}
/** Refresh invalidates earlier replies as well as listings of collapsed folders. */
export function invalidateExplorerDirectoryListings(
updateState: UpdateExplorerState,
retainedPaths: readonly string[],
): void {
const retained = new Set(retainedPaths);
updateState((state) => ({
...state,
listingGeneration: state.listingGeneration + 1,
directories: new Map([...state.directories].filter(([path]) => retained.has(path))),
isLoading: false,
pendingRequest: null,
lastError: null,
}));
}

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