Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 965ee97e4a fix(usage): preserve migrated Go credential discovery
Follow the stored credential selection order so imported keys without an active flag remain discoverable, including when a legacy file is retained.
2026-11-04 03:37:39 +00:00
paseo-bot[bot]andIvan Pantic 02168446fb fix(usage): find Go logins in the configured data directory
Resolve relative database overrides against the OpenCode data directory so usage discovery follows the same store as the CLI.

Co-authored-by: Ivan Pantic <panta@talentkit.io>
2026-11-04 03:27:55 +00:00
Ivan Pantic 0ac8175015 fix(usage): read OpenCode v2 Go API keys from its database 2026-11-04 03:22:55 +00:00
paseo-bot[bot] 66676d49e6 fix(pi): require Reload after the native process exits (#8718)
Reject new turns before accepting work into a known dead Pi runtime. Keep process liveness separate from explicit session cleanup so Reload can still release resources and resume the conversation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 01:56:00 +00:00
paseo-bot[bot] 064243288b Recognize persisted parents when creating child sessions (#8714)
* fix(server): recognize persisted parents during child creation

Creation only needs the caller's identity and workspace. Read the existing stored record when the parent is unloaded after restart, rather than requiring its provider session to be opened first.

* test(server): run persisted caller regressions in integration CI

* test(server): retry fixture removal while Windows releases handles

* fix(server): keep archived parents ineligible for child creation

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 01:12:53 +00:00
Mohamed Boudra a880c9619d feat: background agents for hidden helpers (#8683)
* feat(server): add background agents

An agent can be created background in any workspace. It stays a durable,
addressable agent, but default discovery, workspace status and attention
leave it out. Effective background is agent.background ||
workspace.background.

- Protocol: `create_agent_request.background` now marks the agent itself
  and stays the workspace intent for a workspace the request creates.
  Snapshots carry `background`. The daemon advertises
  `features.backgroundAgents`. Clients advertise the `background_agents`
  capability.
- Daemon: the flag persists in the agent record, so restart and resume
  keep it. AgentDirectory owns visibility. Inclusive reads and
  subscriptions from sources without `background_agents` see only agents
  in background workspaces, because older apps would otherwise open a tab
  for each one.
- Inheritance: omitted on agent-scoped creation, children and new
  workspaces take the caller agent's background.
- MCP: create_agent accepts `background`.
- SDK: placing a background agent in an existing workspace, or under a
  caller, fails fast with "Update the host to use background agents." on
  older hosts.
- CLI: `ls -a` and `ls --background` add a BACKGROUND column.

* feat(app): hide background agents from tabs, track and status

* docs(plugins): point hidden helpers at background agents

* fix: keep background agents hidden across cache reload and connected hosts

* fix: keep remote creation working on older hosts and inherit foreign parents' background
2026-11-04 07:19:21 +07:00
Mohamed Boudra 6740d41fab Open plugin settings from a cog, with a tab per settings screen (#8675)
* feat(app): open plugin settings from a cog with a tab per screen

The plugin row shows a settings button next to its switch when the plugin
registers settings screens. The actions menu keeps Logs, Reload and Remove.
The settings page shows one tab per registered screen; a plugin with one
screen shows no tabs. Tabs swap screenId in place, so existing
plugins/[pluginId]/[screenId] links and openSettings keep working.

* fix(app): render settings tab icons inside the plugin's runtime

Component icons now mount under the installation provider, so plugin SDK
hooks such as useTranslation work. Each icon has its own error boundary;
an icon that throws leaves its tab with the label alone.

* feat(app): name only the plugin in its settings page header

The header shows the plugin's manifest name, or its id when it has none,
for single- and multi-screen plugins alike. The selected tab names the
screen. The Plugins page and the header share one plugin list query.

* feat(app): show plugin display names and a Settings tooltip on the cog

Plugin rows on Settings → Plugins show the manifest name, falling back to
the id; when a name is set, the id stays visible beneath it. The row's
controls, logs sheet, confirmations and feedback use the same name.
The cog is a ToolbarButton labeled Settings, with a matching tooltip.
2026-11-04 07:19:13 +07:00
Mohamed Boudra ca6b54fc2e feat(app): mark composer pills that open a menu and keep the pill track's space (#8673)
Plugin composer pills with a menu or popover now show an upward chevron at
every width, so a pill that opens a surface reads differently from a one-tap
action pill. Built-in Tasks and Subagents pills are unchanged.

The transcript's bottom inset used to be reserved only while some pill
existed. Plugins that add pills when a turn ends and remove them when the next
starts moved the transcript every turn. The inset is now reserved whenever the
agent has an active composer.
2026-11-04 07:19:06 +07: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
242 changed files with 8203 additions and 2671 deletions
+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 |
+30 -11
View File
@@ -183,23 +183,42 @@ Some providers can create their own child sessions inside one provider runtime.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
## Background workspaces
## Background visibility
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
change it. New workspaces default to false, inherit the caller workspace's value when an agent
creates one without an override, and accept explicit true or false.
An agent is effectively background when it or its workspace is background:
`agent.background || workspace.background`. Background controls discovery only. Background agents
and the agents in background workspaces are ordinary durable agents: provider history, plugin
hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their
normal behavior. Both flags are fixed at creation.
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
including History requests, exclude background work; `filter.includeBackground` includes it.
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does
not watch, such as a plugin's classifier in the user's open workspace. A background agent never
raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays
out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still
works.
- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show
background** is on.
- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the
caller's own `background`. A new workspace takes the caller's effective background. A request
that creates its workspace applies `background` to both the agent and the workspace. Explicit
true or false always wins.
`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists.
Default workspace and agent lists, including History requests, exclude background work;
`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app
replicates inclusively and filters at each surface.
Sources without the `background_agents` hello capability get workspace-only visibility on
inclusive reads and subscriptions: agents in background workspaces, but no agent-level background
agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier
run would otherwise open a tab each turn. That visibility has its own directory sync generation.
Sequenced directories keep complete canonical collections. A filtered read must never replace
those collections with its subset. Inclusive and default observations have distinct cursor
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`.
They gate a background agent placed in an existing workspace or under a caller on
`features.backgroundAgents`.
### Private daemon helpers
+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.
+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.
+2 -1
View File
@@ -96,7 +96,8 @@ hidden workspace can overwrite the remembered workspace before Settings or
History returns.
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
Back returns to that host's Plugins page.
Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`,
so switching tabs adds no stack entries.
Settings detail routes are separate siblings on purpose. Keep
`settings/[section]`, the host routes, the projects index, and project detail as
+8 -12
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
+1
View File
@@ -28,6 +28,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Background agent** — An agent hidden from default discovery and from app surfaces that announce agents: tabs, the subagent track, agent lists, workspace status and attention. It is otherwise an ordinary durable agent, addressable by id. An agent is effectively background when it or its workspace is background. Fixed at creation. Code: `background` on `AgentSnapshotPayload` and `create_agent_request`. See [agent-lifecycle.md](agent-lifecycle.md#background-visibility). Don't confuse with background work (provider shells and monitors) or the daemon's private internal helpers, which are never public ([agent-lifecycle.md](agent-lifecycle.md#private-daemon-helpers)).
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
+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.
+1 -1
View File
@@ -1 +1 @@
sha256-iAi82L6Lm75hdg/9tJYyYcHMhCJLtXGSpcNqgv9QQaw=
sha256-VdM4lXdgNEXPpTfvHSFvF9nm302rK/ZbVkZUp9tyOfg=
+7
View File
@@ -38931,6 +38931,7 @@
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.0",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.0",
@@ -39183,6 +39184,7 @@
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
@@ -40081,6 +40083,7 @@
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.0",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40110,6 +40113,7 @@
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
@@ -40151,6 +40155,7 @@
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.0",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40178,6 +40183,7 @@
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.0",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40399,6 +40405,7 @@
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
+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);
});
});
@@ -15,6 +15,8 @@ import {
expectNewWorkspaceForAddedProject,
expectAddProjectResultVisible,
navigateAddProjectResults,
navigateAndDismissAddProject,
watchAddProjectRenderWarnings,
openAddProjectFlow,
openAddProjectHostSelection,
} from "../support/helpers/add-project-flow";
@@ -146,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,
@@ -183,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 }) => {
@@ -0,0 +1,65 @@
import { test } from "../support/fixtures";
import {
expectWorkspaceShownIdle,
finishAndArchiveAgent,
startBackgroundAgent,
} from "../support/helpers/background-agents";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentsTrack,
seedParentWithSubagent,
seedSubagent,
} from "../support/helpers/subagents";
import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Background agents", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "background-agents-",
title: "Classifier workspace",
});
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("a background agent runs in an open workspace without announcing itself", async ({
page,
}) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Feature agent",
childTitle: "Visible helper",
});
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
const classifier = await test.step("a plugin starts a background agent", () =>
startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" }));
// A visible child created after the classifier reaches the app on the same stream, so once
// its row shows, the app has processed the classifier too.
const marker = await seedSubagent(workspace, {
parentId: agents.parent.id,
title: "Ordering marker",
});
await test.step("it stays out of the track, the tabs and the sidebar status", async () => {
await expectSubagentRowVisible(page, marker.id);
await expectSubagentRowGone(page, classifier.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
await expectWorkspaceShownIdle(page, "Classifier workspace");
});
await test.step("the plugin reads its finish and archives it", async () => {
await finishAndArchiveAgent(workspace, classifier.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
await expectSubagentRowGone(page, classifier.id);
});
});
});
@@ -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"]);
});
@@ -10,6 +10,7 @@ test("plugin header and composer buttons share actions, menus, content, and upda
await buttons.verifyObservationLifetime();
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.keepTrackSpaceWithoutPills();
await buttons.openCompactSheets();
await buttons.useCompactWorkspaceMenu();
await buttons.hideAndUnloadOpenButtons();
@@ -16,6 +16,14 @@ import {
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
openPluginsPage,
expectActionsMenuWithoutSettings,
expectNoSettingsCogWithoutScreens,
openTabbedSettings,
selectSettingsTab,
openTabbedSettingsLink,
expectSettingsTooltip,
expectNamedPluginRow,
} from "../support/helpers/plugin-settings";
test("display preferences survive a reload", async ({ page }) => {
@@ -49,6 +57,32 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
await returnToPluginsPage(page);
});
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
await openPluginsPage(page);
await test.step("plugin rows show the display name", async () => {
await expectNamedPluginRow(page);
});
await test.step("the cog explains itself", async () => {
await expectSettingsTooltip(page);
});
await test.step("the actions menu keeps only plugin actions", async () => {
await expectActionsMenuWithoutSettings(page);
});
await test.step("tabs switch between the plugin's settings screens", async () => {
await openTabbedSettings(page);
await selectSettingsTab(page, "Advanced");
await selectSettingsTab(page, "General");
await returnToPluginsPage(page);
});
await test.step("a plugin without settings screens shows no cog", async () => {
await expectNoSettingsCogWithoutScreens(page);
});
});
test("a settings screen link opens its tab", async ({ page }) => {
await openTabbedSettingsLink(page, "Advanced");
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -117,3 +117,25 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
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,48 @@
import { expect, type Page } from "@playwright/test";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { SeededWorkspace } from "./seed-client";
/**
* Starts a background agent the way a plugin helper does: in the workspace, under a parent,
* streaming for about ten seconds. Resolves once the daemon reports it running.
*/
export async function startBackgroundAgent(
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
input: { parentId: string; title: string },
): Promise<{ id: string }> {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: input.title,
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "classify the last turn",
labels: { [PARENT_AGENT_ID_LABEL]: input.parentId },
background: true,
});
await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running");
return { id: agent.id };
}
/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */
export async function finishAndArchiveAgent(
workspace: Pick<SeededWorkspace, "client">,
agentId: string,
): Promise<void> {
const finished = await workspace.client.waitForFinish(agentId, 60_000);
expect(finished.status).toBe("idle");
await workspace.client.archiveAgent(agentId);
}
export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise<void> {
const row = page.getByRole("button", {
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
});
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -4,6 +4,9 @@ import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor";
import { expectTurnCopyButton } from "./agent-stream";
import { submitMessage } from "./composer";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import {
@@ -88,7 +91,7 @@ export default function contribute(client) {
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
command("finish", "Finish deployment", () => finish());
command("finish-review", "Finish review", () => finishReview());
@@ -102,10 +105,16 @@ export default function contribute(client) {
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
const lifetime = new AbortController();
let pillsHidden = false;
const applyWorkspace = (workspace) => {
if (workspace.id !== workspaceId) return;
const hidePills = workspace.name === "No composer pills";
deploy.update({ visible: workspace.name !== "Compact checks" });
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills });
if (hidePills === pillsHidden) return;
pillsHidden = hidePills;
review.update({ visible: !hidePills });
usage.update({ visible: !hidePills });
};
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
subscription.subscribe({
@@ -244,6 +253,38 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject:
await page.emulateMedia({ colorScheme: "light" });
}
const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const;
function chevronOf(page: Page, title: string): Locator {
return page
.getByRole("button", { name: title, exact: true })
.getByTestId("plugin-button-chevron");
}
/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */
async function expectComposerPillChevrons(page: Page) {
await expect(chevronOf(page, "Composer checks")).toHaveCount(1);
await expect(chevronOf(page, "Composer status")).toHaveCount(1);
await expect(chevronOf(page, "Run review")).toHaveCount(0);
}
async function setComposerPillsShown(
page: Page,
showcase: { setTitle(title: string): Promise<unknown> },
shown: boolean,
) {
await showcase.setTitle(shown ? "Button showcase" : "No composer pills");
for (const title of COMPOSER_PILLS) {
await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0);
}
}
async function lastTurnTop(page: Page): Promise<number> {
const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox();
expect(box).not.toBeNull();
return box!.y;
}
async function expectHeaderTextTruncated(page: Page) {
const title = page.getByTestId("workspace-header-title");
const subtitle = page.getByTestId("workspace-header-subtitle");
@@ -276,6 +317,7 @@ export async function withButtonShowcase(
run: (buttons: {
openWideMenusAndPopovers(): Promise<void>;
runAndUpdateActions(): Promise<void>;
keepTrackSpaceWithoutPills(): Promise<void>;
openCompactSheets(): Promise<void>;
useCompactWorkspaceMenu(): Promise<void>;
hideAndUnloadOpenButtons(): Promise<void>;
@@ -348,11 +390,7 @@ export async function withButtonShowcase(
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(1);
await expect(
page
.getByRole("button", { name: "Composer checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await expectComposerPillChevrons(page);
await expectSurfaceVisible(page);
await captureSchemes(
page,
@@ -476,10 +514,31 @@ export async function withButtonShowcase(
"Review ready",
);
}),
keepTrackSpaceWithoutPills: () =>
test.step("the composer track keeps its space when every pill leaves and returns", async () => {
await page.setViewportSize(WIDE);
await submitMessage(page, "Stream a turn under the composer pills");
await expectTurnCopyButton(page);
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
for (const viewport of [WIDE, COMPACT]) {
await page.setViewportSize(viewport);
await scrollAgentChatToBottom(page);
const top = await lastTurnTop(page);
await setComposerPillsShown(page, showcase, false);
expect(await lastTurnTop(page)).toBe(top);
await setComposerPillsShown(page, showcase, true);
expect(await lastTurnTop(page)).toBe(top);
}
await page.setViewportSize(WIDE);
}),
openCompactSheets: () =>
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
).toHaveCount(0);
await expectComposerPillChevrons(page);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2);
await expect(
page.getByRole("button", { name: "Composer checks", exact: true }),
).toContainText("Checks");
@@ -1,4 +1,7 @@
import { copyPluginExample } from "./plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
@@ -9,6 +12,54 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const TABBED_PLUGIN_ID = "settings-tabs";
const TABBED_PLUGIN_NAME = "Tabbed settings";
/** Two settings screens, so the settings page shows a tab per screen. */
const TABBED_SOURCE = `import React from "react";
import { Text, View } from "react-native";
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
function General({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
}
function Advanced({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
}
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
function TranslatedIcon({ color, size }) {
const { t } = useTranslation(messages);
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function BrokenIcon() {
throw new Error("Settings tab icon exploded");
}
export default function contribute(client) {
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
return () => {};
}
`;
async function writeTabbedPlugin() {
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: TABBED_PLUGIN_ID,
name: TABBED_PLUGIN_NAME,
requirements: pluginRequirements,
}),
);
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
}
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
settingsPlugin: [
async ({ e2eWorker, page }, provide, testInfo) => {
@@ -16,16 +67,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("settings");
const tabbed = await writeTabbedPlugin();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await client.installDirectoryPlugin(tabbed.directory);
await provide(client);
await page.screenshot({ path: testInfo.outputPath("settings.png") });
} finally {
await client.removePlugin("settings-example");
await client.removePlugin(TABBED_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await tabbed.cleanup();
}
},
{ auto: true },
@@ -44,9 +99,88 @@ export async function openCompactDisplaySettings(page: Page) {
}
async function openPluginScreen(page: Page) {
await openHostSection(page, getServerId(), "plugins");
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await openSettingsFromCog(page);
}
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
function settingsButton(page: Page, pluginId: string) {
return page
.getByTestId(`plugin-row-${pluginId}`)
.filter({ visible: true })
.getByRole("button", { name: "Settings", exact: true });
}
async function openSettingsFromCog(page: Page) {
await settingsButton(page, "settings-example").click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
await expectSettingsHeader(page, "settings-example");
}
export async function openPluginsPage(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openHostSection(page, getServerId(), "plugins");
}
export async function expectActionsMenuWithoutSettings(page: Page) {
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
export async function expectNoSettingsCogWithoutScreens(page: Page) {
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
await expect(settingsButton(page, "settings-example")).toBeVisible();
}
export async function expectSettingsTooltip(page: Page) {
await settingsButton(page, "settings-example").hover();
await expect(page.getByRole("tooltip")).toHaveText("Settings");
await page.mouse.move(0, 0);
}
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
export async function expectNamedPluginRow(page: Page) {
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
await expect(
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
).toBeVisible();
}
export async function openTabbedSettings(page: Page) {
await settingsButton(page, TABBED_PLUGIN_ID).click();
await expectSettingsTab(page, "General");
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
}
function settingsTab(page: Page, title: string) {
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
}
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
await settingsTab(page, title).click();
await expectSettingsTab(page, title);
}
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
const other = title === "General" ? "Advanced" : "General";
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
// The header names the plugin by its manifest name; the tab names the screen.
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
await expectAppRoute(
page,
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
);
}
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
await gotoAppShell(page);
await page.goto(
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
);
await expectSettingsTab(page, title);
}
export async function groupByWorkspace(page: Page) {
await page.getByRole("button", { name: /^Group agents by:/ }).click();
@@ -101,11 +235,7 @@ export async function discardTitle(page: Page) {
}
export async function returnAndReopenSettings(page: Page) {
await goBackInSettings(page);
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
await expect(actions).toBeVisible();
await actions.click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
await openSettingsFromCog(page);
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
@@ -114,6 +244,10 @@ export async function returnToPluginsPage(page: Page) {
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
async function expectSettingsHeader(page: Page, title: string) {
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
@@ -118,6 +118,7 @@ export interface SeedDaemonClient {
featureValues?: Record<string, unknown>;
initialPrompt?: string;
labels?: Record<string, string>;
background?: boolean;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
@@ -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)
}
+1 -1
View File
@@ -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();
+7 -2
View File
@@ -1,3 +1,4 @@
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";
@@ -90,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";
@@ -549,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>
@@ -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,
@@ -655,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?.();
@@ -683,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();
@@ -693,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(() => {
@@ -735,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>
);
}
@@ -790,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,4 @@
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
@@ -69,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,
@@ -302,7 +299,7 @@ function FlowRow({
}
function FlowHint({ keys, action }: { keys: string[]; action: string }) {
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const shortcutsAvailable = useHardwareKeyboardPresent();
if (!shortcutsAvailable) return null;
return (
@@ -460,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) => {
@@ -815,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"
@@ -873,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}`}
@@ -892,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}
@@ -996,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) => ({
@@ -1070,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"
@@ -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 }),
@@ -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();
@@ -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;
+3 -2
View File
@@ -29,8 +29,9 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
* on every platform.
*
* The bar floats over the transcript with no background, so content remains visible underneath.
* Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
* the final footer clear without turning the overlay into a layout band.
* Its host gives the scroll viewport a small bottom inset for as long as the bar is mounted, with
* or without pills: that keeps the final footer clear, and pills that come and go every turn do not
* move the transcript.
*/
export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
return (
+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<
@@ -12,7 +12,7 @@ import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
import { usePaneFocus } from "@/panels/pane-context";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { hasActiveOverlay } from "@/keyboard";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { FileFindModel } from "./model.web";
@@ -39,7 +39,7 @@ export function FileFind({
useEffect(() => {
if (!isInteractive || !active) return;
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
if (event.defaultPrevented || hasActiveOverlay() || isImeComposingKeyboardEvent(event))
return;
if (isFindShortcut(event, findShortcutPlatform())) {
event.preventDefault();
@@ -76,7 +76,7 @@ export function useAggregatedAgents(options?: {
}
const serverLabel = serverLabelById.get(serverId) ?? serverId;
for (const agent of agents.values()) {
if (!includeArchived && agent.archivedAt) {
if (agent.background || (!includeArchived && agent.archivedAt)) {
continue;
}
const nextAgent: AggregatedAgent = {
@@ -1,432 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { usePathname, useRouter } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import {
type ChordState,
type KeyboardShortcutInput,
resolveKeyboardShortcut,
buildEffectiveBindings,
getWorkspaceIndexJumpModifierKey,
} from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import {
buildBrowserKeyboardPolicy,
parseBrowserShortcutInput,
shouldPublishBrowserShortcutPolicy,
} from "@/desktop/browser/shortcuts";
import type { KeyboardFocusScope, KeyboardShortcutPayload } from "@/keyboard/actions";
import {
routeKeyboardShortcut,
type ShortcutAction,
type ShortcutCallbackName,
} from "@/keyboard/route-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import {
type ActiveWorkspaceSelection,
navigateToLastWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { dispatchTopWebOverlayKeyDown, keyBelongsToDomPopover } from "@/lib/overlay-root";
export function useKeyboardShortcuts({
enabled,
isMobile,
isWorkspaceFocusModeEnabled,
toggleAgentList,
toggleBothSidebars,
exitFocusMode,
cycleTheme,
}: {
enabled: boolean;
isMobile: boolean;
isWorkspaceFocusModeEnabled: boolean;
toggleAgentList: () => void;
toggleBothSidebars?: () => void;
exitFocusMode: () => void;
cycleTheme?: () => void;
}) {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const pathname = usePathname();
const router = useRouter();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
const shortcutsAvailable = keyboardShortcutsAvailable({ isNative, isCompact: isMobile });
const isDesktopApp = getIsElectronRuntime();
const isMac = getShortcutOs() === "mac";
const chordStateRef = useRef<ChordState>({
candidateIndices: [],
step: 0,
timeoutId: null,
});
const openProjectPickerAction = useOpenAddProject();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
const publishBrowserShortcutPolicy = useCallback(
(chordState?: ChordState) => {
const policy =
enabled && shortcutsAvailable
? buildBrowserKeyboardPolicy({
bindings,
chordState,
isMac,
isDesktop: isDesktopApp,
})
: { menuPrefixes: [], prefixes: [] };
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
},
[bindings, enabled, isDesktopApp, isMac, shortcutsAvailable],
);
useEffect(() => {
if (activeWorkspaceSelection) {
keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection;
}
}, [activeWorkspaceSelection]);
useEffect(() => {
if (!isDesktopApp) {
return;
}
publishBrowserShortcutPolicy();
}, [isDesktopApp, publishBrowserShortcutPolicy]);
// Only the modifier that actually performs the workspace-index jump on this
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
// to drive badge visibility, so we set the flag matching this runtime.
// Derived from the effective bindings: `null` when the user unassigned or
// rebound the jump shortcut, and no `event.key` ever equals null, so the
// badges simply never appear.
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
{ isMac, isDesktop: isDesktopApp },
bindings,
);
// The keyup listener matches the released key against the current modifier,
// so a modifier held while the jump binding changes could never be released
// and the badges would stay up until a blur. Clear them when it changes.
useEffect(() => {
resetModifiers();
}, [badgeModifierKey, resetModifiers]);
const setBadgeModifierDown = (down: boolean) => {
const state = useKeyboardShortcutsStore.getState();
if (isDesktopApp) {
state.setCmdOrCtrlDown(down);
} else {
state.setAltDown(down);
}
};
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
};
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
"toggle-agent-list": toggleAgentList,
"toggle-both-sidebars": toggleBothSidebars,
"cycle-theme": cycleTheme,
};
const performShortcutAction = (
action: ShortcutAction,
event: KeyboardEvent | null,
browserFocusRestoreElement: HTMLElement | null = null,
): boolean => {
switch (action.kind) {
case "none":
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "navigate-workspace":
keyboardWorkspaceSelectionRef.current = {
serverId: action.serverId,
workspaceId: action.workspaceId,
};
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
return true;
case "navigate-last-workspace":
if (navigateToLastWorkspace()) {
return true;
}
router.replace(buildOpenProjectRoute());
return true;
case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]);
return true;
case "router-back":
router.back();
return true;
case "router-push":
router.push(action.route as Parameters<typeof router.push>[0]);
return true;
case "open-project-picker":
void openProjectPickerAction();
return true;
case "callback":
callbacksByName[action.name]?.();
return true;
case "command-center-toggle": {
if (action.nextOpen) {
if (event) {
captureCommandCenterFocusRestore(event);
} else {
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
}
}
useKeyboardShortcutsStore
.getState()
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
return true;
}
case "shortcuts-dialog-toggle":
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
return true;
}
};
const routeAndPerformShortcut = (input: {
action: string;
payload: KeyboardShortcutPayload;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}): boolean => {
const store = useKeyboardShortcutsStore.getState();
const shortcutAction = routeKeyboardShortcut(
{ action: input.action, payload: input.payload },
{
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace:
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
);
const handled = performShortcutAction(
shortcutAction,
input.domEvent,
input.browserFocusRestoreElement,
);
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
exitFocusMode();
}
return handled;
};
const resolveAndPerformShortcut = (input: {
event: KeyboardShortcutInput;
focusScope: KeyboardFocusScope;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}) => {
const store = useKeyboardShortcutsStore.getState();
const previousChordState = chordStateRef.current;
const result = resolveKeyboardShortcut({
event: input.event,
context: {
isMac,
isDesktop: isDesktopApp,
focusScope: input.focusScope,
commandCenterOpen: store.commandCenterOpen,
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
publishBrowserShortcutPolicy();
},
bindings,
});
chordStateRef.current = result.nextChordState;
if (
shouldPublishBrowserShortcutPolicy({
isBrowserInput: "browserId" in input.event,
previousChordState,
nextChordState: result.nextChordState,
})
) {
publishBrowserShortcutPolicy(result.nextChordState);
}
if (result.preventDefault && input.domEvent) {
input.domEvent.preventDefault();
input.domEvent.stopPropagation();
}
if (!result.match) {
return;
}
const handled = routeAndPerformShortcut({
action: result.match.action,
payload: result.match.payload,
domEvent: input.domEvent,
browserFocusRestoreElement: input.browserFocusRestoreElement,
});
if (!handled || !input.domEvent) {
return;
}
if (result.match.preventDefault) {
input.domEvent.preventDefault();
}
if (result.match.stopPropagation) {
input.domEvent.stopPropagation();
}
};
// The window listeners must outlive ordinary re-renders: removing them resets
// any in-progress chord. Stable events let them read the latest render instead
// of being re-registered whenever a route, callback, or selection changes.
const handleKeyDown = useStableEvent((event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
if (dispatchTopWebOverlayKeyDown(event)) {
return;
}
if (keyBelongsToDomPopover(event)) {
return;
}
// During IME composition, Enter confirms the candidate selection and must
// not route through global shortcuts like message send.
if (isImeComposingKeyboardEvent(event)) {
return;
}
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;
}
const key = event.key ?? "";
if (
key === "Escape" &&
pathname.startsWith("/settings") &&
!isMobile &&
hasActiveWebOverlay()
) {
return;
}
if (key === badgeModifierKey && !event.shiftKey) {
setBadgeModifierDown(true);
}
if (key === "Shift") {
const state = useKeyboardShortcutsStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
resolveAndPerformShortcut({
event,
focusScope,
domEvent: event,
});
});
const handleKeyUp = useStableEvent((event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === badgeModifierKey) {
setBadgeModifierDown(false);
}
});
const handleBrowserShortcutInput = useStableEvent((payload: unknown) => {
const input = parseBrowserShortcutInput(payload);
if (!input) {
return;
}
resolveAndPerformShortcut({
event: input,
focusScope: "browser",
domEvent: null,
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
});
});
useEffect(() => {
if (!enabled) return;
if (!shortcutsAvailable) return;
const handleBlurOrHide = () => {
resetModifiers();
};
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
const browserShortcutSubscription = isElectronRuntime()
? getDesktopHost()?.events?.on?.("browser-shortcut-input", handleBrowserShortcutInput)
: null;
return () => {
if (chordStateRef.current.timeoutId !== null) {
clearTimeout(chordStateRef.current.timeoutId);
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
if (typeof browserShortcutSubscription === "function") {
browserShortcutSubscription();
} else {
void browserShortcutSubscription?.then((dispose) => dispose());
}
};
}, [
enabled,
handleBrowserShortcutInput,
handleKeyDown,
handleKeyUp,
resetModifiers,
shortcutsAvailable,
]);
}
+3 -4
View File
@@ -1,21 +1,20 @@
import { usesDesktopBindings } from "@/keyboard";
import { useMemo } from "react";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
/** `null` action ids are accepted so callers can keep the hook unconditional. */
export function useShortcutKeys(actionId: string | null): ShortcutKey[][] | null {
const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
return useMemo(() => {
if (actionId === null) return null;
return resolveShortcutKeysForAction(actionId, overrides, {
isMac,
isDesktop: isDesktopApp,
usesDesktopBindings,
});
}, [actionId, overrides, isMac, isDesktopApp]);
}, [actionId, overrides, isMac]);
}
+46 -44
View File
@@ -1,3 +1,4 @@
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -12,11 +13,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import { isWeb } from "@/constants/platform";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
@@ -145,7 +142,7 @@ export function HostChooserModal() {
const inputRef = useRef<EditingTextInputHandle>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null);
const modalLayer = useGlobalOverlayLayer("modal", request != null);
const requestHosts = useMemo(() => {
if (!request) return [];
@@ -186,8 +183,8 @@ export function HostChooserModal() {
[close, request],
);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
@@ -227,51 +224,56 @@ export function HostChooserModal() {
},
[activeOptionIndex, chooseHost, close, options],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && request != null,
const setOverlayScope = useOverlayRegistration({
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
active: request != null,
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
onKeyDown: handleOverlayKeyDown,
});
if (!request) return null;
const modal = (
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={close} />
<View ref={setWebOverlayScope} style={styles.panel}>
<View style={styles.header}>
<Text style={styles.title}>{request.title}</Text>
<TextInput
ref={inputRef}
initialValue={query}
onChangeText={handleQueryChange}
placeholder="Search hosts..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
testID="host-chooser-search"
/>
</View>
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{options.length === 0 ? <Text style={styles.emptyText}>No matching hosts</Text> : null}
{options.map((host, index) => (
<HostChooserRow
key={host.serverId}
host={host}
active={index === activeOptionIndex}
onChooseHost={chooseHost}
<OverlayLayerProvider layer={modalLayer}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={close} />
<View ref={setOverlayScope} style={styles.panel}>
<View style={styles.header}>
<Text style={styles.title}>{request.title}</Text>
<TextInput
ref={inputRef}
initialValue={query}
onChangeText={handleQueryChange}
placeholder="Search hosts..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
testID="host-chooser-search"
/>
))}
</ScrollView>
</View>
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{options.length === 0 ? (
<Text style={styles.emptyText}>No matching hosts</Text>
) : null}
{options.map((host, index) => (
<HostChooserRow
key={host.serverId}
host={host}
active={index === activeOptionIndex}
onChooseHost={chooseHost}
/>
))}
</ScrollView>
</View>
</View>
</View>
</OverlayLayerProvider>
</Modal>
);
+10 -9
View File
@@ -2384,7 +2384,7 @@ export const en = {
installing: "Installing…",
logs: {
action: "Logs",
title: "Logs: {{id}}",
title: "Logs: {{name}}",
loading: "Loading logs…",
refresh: "Refresh",
refreshing: "Refreshing…",
@@ -2393,7 +2393,8 @@ export const en = {
},
status: { running: "running", disabled: "disabled", failed: "failed" },
actions: {
menu: "Actions for {{id}}",
menu: "Actions for {{name}}",
settings: "Settings",
reload: "Reload",
reloading: "Reloading…",
enable: "Enable",
@@ -2404,16 +2405,16 @@ export const en = {
removing: "Removing…",
},
feedback: {
installed: "Installed {{id}}",
reload: "Reloaded {{id}}",
enable: "Enabled {{id}}",
disable: "Disabled {{id}}",
remove: "Removed {{id}}",
kept: "Kept {{id}}",
installed: "Installed {{name}}",
reload: "Reloaded {{name}}",
enable: "Enabled {{name}}",
disable: "Disabled {{name}}",
remove: "Removed {{name}}",
kept: "Kept {{name}}",
globalEnabled: "Plugins enabled",
globalDisabled: "Plugins disabled",
},
removeConfirmTitle: "Remove {{id}}?",
removeConfirmTitle: "Remove {{name}}?",
removeConfirmMessage: "This removes the configuration. The source directory is not deleted.",
states: {
offlineTitle: "Plugin host is offline",
@@ -1,7 +1,7 @@
const shared = {
logs: {
action: "Logs",
title: "Logs: {{id}}",
title: "Logs: {{name}}",
loading: "Loading logs…",
refresh: "Refresh",
refreshing: "Refreshing…",
@@ -33,7 +33,8 @@ export const pluginSettings = {
installing: "جارٍ التثبيت…",
status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" },
actions: {
menu: "إجراءات {{id}}",
menu: "إجراءات {{name}}",
settings: "الإعدادات",
reload: "إعادة تحميل",
reloading: "جارٍ إعادة التحميل…",
enable: "تفعيل",
@@ -44,16 +45,16 @@ export const pluginSettings = {
removing: "جارٍ الإزالة…",
},
feedback: {
installed: "تم تثبيت {{id}}",
reload: "تمت إعادة تحميل {{id}}",
enable: "تم تفعيل {{id}}",
disable: "تم تعطيل {{id}}",
remove: "تمت إزالة {{id}}",
kept: "تم الإبقاء على {{id}}",
installed: "تم تثبيت {{name}}",
reload: "تمت إعادة تحميل {{name}}",
enable: "تم تفعيل {{name}}",
disable: "تم تعطيل {{name}}",
remove: "تمت إزالة {{name}}",
kept: "تم الإبقاء على {{name}}",
globalEnabled: "تم تفعيل الإضافات",
globalDisabled: "تم تعطيل الإضافات",
},
removeConfirmTitle: "إزالة {{id}}؟",
removeConfirmTitle: "إزالة {{name}}؟",
removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.",
states: {
offlineTitle: "مضيف الإضافات غير متصل",
@@ -88,7 +89,8 @@ export const pluginSettings = {
installing: "Instalando…",
status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" },
actions: {
menu: "Acciones para {{id}}",
menu: "Acciones para {{name}}",
settings: "Configuración",
reload: "Recargar",
reloading: "Recargando…",
enable: "Activar",
@@ -99,16 +101,16 @@ export const pluginSettings = {
removing: "Eliminando…",
},
feedback: {
installed: "{{id}} instalado",
reload: "{{id}} recargado",
enable: "{{id}} activado",
disable: "{{id}} desactivado",
remove: "{{id}} eliminado",
kept: "Se conservó {{id}}",
installed: "{{name}} instalado",
reload: "{{name}} recargado",
enable: "{{name}} activado",
disable: "{{name}} desactivado",
remove: "{{name}} eliminado",
kept: "Se conservó {{name}}",
globalEnabled: "Plugins activados",
globalDisabled: "Plugins desactivados",
},
removeConfirmTitle: "¿Eliminar {{id}}?",
removeConfirmTitle: "¿Eliminar {{name}}?",
removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.",
states: {
offlineTitle: "El host de plugins está sin conexión",
@@ -143,7 +145,8 @@ export const pluginSettings = {
installing: "Installation…",
status: { running: "actif", disabled: "désactivé", failed: "échec" },
actions: {
menu: "Actions pour {{id}}",
menu: "Actions pour {{name}}",
settings: "Paramètres",
reload: "Recharger",
reloading: "Rechargement…",
enable: "Activer",
@@ -154,16 +157,16 @@ export const pluginSettings = {
removing: "Suppression…",
},
feedback: {
installed: "{{id}} installé",
reload: "{{id}} rechargé",
enable: "{{id}} activé",
disable: "{{id}} désactivé",
remove: "{{id}} supprimé",
kept: "{{id}} conservé",
installed: "{{name}} installé",
reload: "{{name}} rechargé",
enable: "{{name}} activé",
disable: "{{name}} désactivé",
remove: "{{name}} supprimé",
kept: "{{name}} conservé",
globalEnabled: "Plugins activés",
globalDisabled: "Plugins désactivés",
},
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmTitle: "Supprimer {{name}} ?",
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
states: {
offlineTitle: "L’hôte des plugins est hors ligne",
@@ -198,7 +201,8 @@ export const pluginSettings = {
installing: "インストール中…",
status: { running: "実行中", disabled: "無効", failed: "失敗" },
actions: {
menu: "{{id}} のアクション",
menu: "{{name}} のアクション",
settings: "設定",
reload: "再読み込み",
reloading: "再読み込み中…",
enable: "有効化",
@@ -209,16 +213,16 @@ export const pluginSettings = {
removing: "削除中…",
},
feedback: {
installed: "{{id}} をインストールしました",
reload: "{{id}} を再読み込みしました",
enable: "{{id}} を有効にしました",
disable: "{{id}} を無効にしました",
remove: "{{id}} を削除しました",
kept: "{{id}} を保持しました",
installed: "{{name}} をインストールしました",
reload: "{{name}} を再読み込みしました",
enable: "{{name}} を有効にしました",
disable: "{{name}} を無効にしました",
remove: "{{name}} を削除しました",
kept: "{{name}} を保持しました",
globalEnabled: "プラグインを有効にしました",
globalDisabled: "プラグインを無効にしました",
},
removeConfirmTitle: "{{id}} を削除しますか?",
removeConfirmTitle: "{{name}} を削除しますか?",
removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。",
states: {
offlineTitle: "プラグインホストはオフラインです",
@@ -252,7 +256,8 @@ export const pluginSettings = {
installing: "설치 중…",
status: { running: "실행 중", disabled: "비활성화", failed: "실패" },
actions: {
menu: "{{id}} 작업",
menu: "{{name}} 작업",
settings: "설정",
reload: "다시 로드",
reloading: "다시 로드 중…",
enable: "활성화",
@@ -263,16 +268,16 @@ export const pluginSettings = {
removing: "제거 중…",
},
feedback: {
installed: "{{id}} 설치됨",
reload: "{{id}} 다시 로드됨",
enable: "{{id}} 활성화됨",
disable: "{{id}} 비활성화됨",
remove: "{{id}} 제거됨",
kept: "{{id}} 유지됨",
installed: "{{name}} 설치됨",
reload: "{{name}} 다시 로드됨",
enable: "{{name}} 활성화됨",
disable: "{{name}} 비활성화됨",
remove: "{{name}} 제거됨",
kept: "{{name}} 유지됨",
globalEnabled: "플러그인 활성화됨",
globalDisabled: "플러그인 비활성화됨",
},
removeConfirmTitle: "{{id}}을(를) 제거할까요?",
removeConfirmTitle: "{{name}}을(를) 제거할까요?",
removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.",
states: {
offlineTitle: "플러그인 호스트가 오프라인입니다",
@@ -307,7 +312,8 @@ export const pluginSettings = {
installing: "Instalando…",
status: { running: "em execução", disabled: "desativado", failed: "falhou" },
actions: {
menu: "Ações para {{id}}",
menu: "Ações para {{name}}",
settings: "Configurações",
reload: "Recarregar",
reloading: "Recarregando…",
enable: "Ativar",
@@ -318,16 +324,16 @@ export const pluginSettings = {
removing: "Removendo…",
},
feedback: {
installed: "{{id}} instalado",
reload: "{{id}} recarregado",
enable: "{{id}} ativado",
disable: "{{id}} desativado",
remove: "{{id}} removido",
kept: "{{id}} mantido",
installed: "{{name}} instalado",
reload: "{{name}} recarregado",
enable: "{{name}} ativado",
disable: "{{name}} desativado",
remove: "{{name}} removido",
kept: "{{name}} mantido",
globalEnabled: "Plugins ativados",
globalDisabled: "Plugins desativados",
},
removeConfirmTitle: "Remover {{id}}?",
removeConfirmTitle: "Remover {{name}}?",
removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.",
states: {
offlineTitle: "O host de plugins está offline",
@@ -362,7 +368,8 @@ export const pluginSettings = {
installing: "Установка…",
status: { running: "работает", disabled: "отключён", failed: "ошибка" },
actions: {
menu: "Действия для {{id}}",
menu: "Действия для {{name}}",
settings: "Настройки",
reload: "Перезагрузить",
reloading: "Перезагрузка…",
enable: "Включить",
@@ -373,16 +380,16 @@ export const pluginSettings = {
removing: "Удаление…",
},
feedback: {
installed: "{{id}} установлен",
reload: "{{id}} перезагружен",
enable: "{{id}} включён",
disable: "{{id}} отключён",
remove: "{{id}} удалён",
kept: "{{id}} сохранён",
installed: "{{name}} установлен",
reload: "{{name}} перезагружен",
enable: "{{name}} включён",
disable: "{{name}} отключён",
remove: "{{name}} удалён",
kept: "{{name}} сохранён",
globalEnabled: "Плагины включены",
globalDisabled: "Плагины отключены",
},
removeConfirmTitle: "Удалить {{id}}?",
removeConfirmTitle: "Удалить {{name}}?",
removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.",
states: {
offlineTitle: "Хост плагинов не в сети",
@@ -416,7 +423,8 @@ export const pluginSettings = {
installing: "正在安装…",
status: { running: "运行中", disabled: "已禁用", failed: "失败" },
actions: {
menu: "{{id}} 的操作",
menu: "{{name}} 的操作",
settings: "设置",
reload: "重新加载",
reloading: "正在重新加载…",
enable: "启用",
@@ -427,16 +435,16 @@ export const pluginSettings = {
removing: "正在移除…",
},
feedback: {
installed: "已安装 {{id}}",
reload: "已重新加载 {{id}}",
enable: "已启用 {{id}}",
disable: "已禁用 {{id}}",
remove: "已移除 {{id}}",
kept: "已保留 {{id}}",
installed: "已安装 {{name}}",
reload: "已重新加载 {{name}}",
enable: "已启用 {{name}}",
disable: "已禁用 {{name}}",
remove: "已移除 {{name}}",
kept: "已保留 {{name}}",
globalEnabled: "插件已启用",
globalDisabled: "插件已禁用",
},
removeConfirmTitle: "移除 {{id}}?",
removeConfirmTitle: "移除 {{name}}?",
removeConfirmMessage: "这会移除配置,但不会删除源目录。",
states: {
offlineTitle: "插件主机离线",
+64 -7
View File
@@ -1,11 +1,68 @@
import { describe, expect, it } from "vitest";
import { keyboardShortcutsAvailable } from "./availability";
import { createHardwareKeyboardPresence } from "./internal/hardware-presence";
import { buildEffectiveBindings } from "./keyboard-shortcuts";
import { createKeyboardPipeline } from "./internal/pipeline";
import { createOverlayRegistry } from "./internal/overlays";
import { nativeKeyboardInput } from "./internal/sources/contract";
describe("keyboardShortcutsAvailable", () => {
it("matches the environments where the shortcut dispatcher runs", () => {
expect(keyboardShortcutsAvailable({ isNative: false, isCompact: false })).toBe(true);
expect(keyboardShortcutsAvailable({ isNative: false, isCompact: true })).toBe(false);
expect(keyboardShortcutsAvailable({ isNative: true, isCompact: false })).toBe(false);
expect(keyboardShortcutsAvailable({ isNative: true, isCompact: true })).toBe(false);
describe("hardware keyboard availability", () => {
it("publishes connection changes and cleans up the capability subscription", () => {
let present = false;
const changes = new Set<() => void>();
const presence = createHardwareKeyboardPresence({
read: () => present,
watch: (changed) => {
changes.add(changed);
return () => {
changes.delete(changed);
};
},
});
const observed: boolean[] = [];
const release = presence.subscribe(() => observed.push(presence.getSnapshot()));
expect(presence.getSnapshot()).toBe(false);
present = true;
for (const changed of changes) changed();
expect(presence.getSnapshot()).toBe(true);
for (const changed of changes) changed();
present = false;
for (const changed of changes) changed();
expect(observed).toEqual([true, false]);
release();
expect(changes.size).toBe(0);
});
it("routes compact Electron input independently of hint availability", () => {
const presence = createHardwareKeyboardPresence({ read: () => false, watch: () => () => {} });
const actions: string[] = [];
const pipeline = createKeyboardPipeline({
getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }),
getBindings: () => buildEffectiveBindings({}),
isCapturing: () => false,
dispatch: (match) => {
actions.push(match.action);
return true;
},
overlays: createOverlayRegistry(),
});
pipeline.keyDown(
nativeKeyboardInput(
{
key: "k",
code: "KeyK",
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false,
repeat: false,
preventDefault() {},
stopPropagation() {},
},
"other",
),
);
expect(presence.getSnapshot()).toBe(false);
expect(actions).toEqual(["command-center.toggle"]);
pipeline.dispose();
});
});
+8 -18
View File
@@ -1,19 +1,9 @@
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
interface KeyboardShortcutEnvironment {
isNative: boolean;
isCompact: boolean;
}
export function keyboardShortcutsAvailable({
isNative: native,
isCompact,
}: KeyboardShortcutEnvironment): boolean {
return !native && !isCompact;
}
export function useKeyboardShortcutsAvailable(): boolean {
const isCompact = useIsCompactFormFactor();
return keyboardShortcutsAvailable({ isNative, isCompact });
import { useSyncExternalStore } from "react";
import { hardwareKeyboardPresence } from "./internal/presence";
export function useHardwareKeyboardPresent(): boolean {
return useSyncExternalStore(
hardwareKeyboardPresence.subscribe,
hardwareKeyboardPresence.getSnapshot,
() => false,
);
}
+13
View File
@@ -0,0 +1,13 @@
export { useOverlayRegistration } from "./internal/overlay-registration";
export type { KeyboardInputEvent } from "./internal/overlays";
export {
dispatchTopOverlayKeyDown,
hasActiveOverlay,
getTopOverlayKeys,
getTopOverlayLayer,
subscribeOverlays,
} from "./internal/overlay-dispatch";
export { useKeyboardFocusSurface } from "./internal/use-focus-surface";
export { usesDesktopBindings, supportsConfigurableComposerSendKeys } from "./internal/conventions";
export type { BrowserKeyboardPolicy } from "./internal/sources/electron-browser";
export { useHardwareKeyboardPresent } from "./availability";
@@ -0,0 +1,3 @@
export const usesDesktopBindings = true;
// Native composer Return retains its existing submit path in this feature.
export const supportsConfigurableComposerSendKeys = false;
@@ -0,0 +1,3 @@
import { isElectronRuntime } from "@/desktop/host";
export const usesDesktopBindings = isElectronRuntime();
export const supportsConfigurableComposerSendKeys = true;
@@ -0,0 +1,6 @@
export function keyBelongsToDomPopover(_event: { key: string }): boolean {
return false;
}
export function hasOpenDomPopover(): boolean {
return false;
}
@@ -0,0 +1,24 @@
/**
* True while an element shown with the Popover API is open. Such a layer
* floats above the app like a registered overlay but lives outside the
* registry: plugins draw their panels this way, since they cannot register.
*/
export function hasOpenDomPopover(): boolean {
if (typeof document === "undefined") return false;
try {
return document.querySelector(":popover-open") !== null;
} catch {
// An engine without the Popover API rejects the selector, and has none open.
return false;
}
}
/**
* Whether a key belongs to an open popover rather than to the app's shortcuts.
* An open popover (a plugin's panel) is the top layer and closes on its own
* Escape listener, which runs after the shortcut engine's capture listener:
* Escape must not also interrupt the running agent.
*/
export function keyBelongsToDomPopover(event: Pick<KeyboardEvent, "key">): boolean {
return event.key === "Escape" && hasOpenDomPopover();
}
@@ -0,0 +1,23 @@
import { hasFocusedTextInput } from "./focused-text-input";
import type { KeyboardFocusScope } from "../actions";
export type KeyboardFocusSurface = "message-input" | "terminal";
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
const surfaces = new Map<symbol, KeyboardFocusSurface>();
return {
declare(surface: KeyboardFocusSurface) {
const token = Symbol(surface);
surfaces.set(token, surface);
return () => {
surfaces.delete(token);
};
},
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
if (commandCenterOpen) return "command-center";
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
for (const surface of surfaces.values()) focused = surface;
return focused;
},
};
}
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
@@ -0,0 +1,6 @@
// oxlint-disable-next-line no-restricted-imports -- Keyboard boundary reads focus state; it does not render an input.
import { TextInput } from "react-native";
export function hasFocusedTextInput(): boolean {
return TextInput.State.currentlyFocusedInput() !== null;
}
@@ -0,0 +1,3 @@
export function hasFocusedTextInput(): boolean {
return false;
}
@@ -0,0 +1,32 @@
interface PresenceSource {
read(): boolean;
watch(changed: () => void): () => void;
}
export function createHardwareKeyboardPresence(source: PresenceSource) {
let present = source.read();
const listeners = new Set<() => void>();
let stop: (() => void) | undefined;
const update = () => {
const next = source.read();
if (present === next) return;
present = next;
for (const listener of listeners) listener();
};
return {
getSnapshot: () => present,
subscribe(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1) {
stop = source.watch(update);
update();
}
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
stop?.();
stop = undefined;
}
};
},
};
}
@@ -0,0 +1,10 @@
import type { RefObject } from "react";
import type { KeyboardInputEvent } from "./overlays";
export interface OverlayRegistration {
active: boolean;
layer: number;
keys: readonly string[];
onKeyDown(event: KeyboardInputEvent): boolean;
restoreFocusRef?: RefObject<unknown>;
manageFocus?: boolean;
}
@@ -0,0 +1,8 @@
import { overlays } from "./overlays";
export const dispatchTopOverlayKeyDown = overlays.dispatch;
export const hasActiveOverlay = overlays.hasActive;
export const getTopOverlayKeys = overlays.getKeys;
export const subscribeOverlays = overlays.subscribe;
export function getTopOverlayLayer(): number {
return overlays.getTop()?.getLayer() ?? 0;
}
@@ -0,0 +1,18 @@
import type { OverlayRegistration } from "./overlay-contract";
import { useLayoutEffect, useRef } from "react";
import { overlays } from "./overlays";
const setScope = (_node: unknown) => {};
export function useOverlayRegistration(input: OverlayRegistration) {
const current = useRef(input);
current.current = input;
useLayoutEffect(() => {
if (!input.active) return;
return overlays.register({
getLayer: () => current.current.layer,
getKeys: () => current.current.keys,
onKeyDown: (event) => current.current.onKeyDown(event),
});
}, [input.active]);
return setScope;
}
@@ -0,0 +1,264 @@
import { useCallback, useLayoutEffect, useRef } from "react";
import { overlays, type OverlayEntry, type KeyboardInputEvent } from "./overlays";
import type { OverlayRegistration } from "./overlay-contract";
type WebOverlayKeyHandler = (event: KeyboardInputEvent) => boolean;
interface WebOverlayEntry {
getLayer: () => number;
getKeys: () => readonly string[];
getScope: () => HTMLElement | null;
getKeyHandler: () => WebOverlayKeyHandler;
restoreFocus: HTMLElement | null;
manageFocus: boolean;
}
const focusEntries = new WeakMap<OverlayEntry, WebOverlayEntry>();
let webOverlayListenersAttached = false;
let webOverlayFocusCheckQueued = false;
interface RemoveWebOverlayOptions {
restoreFocus?: boolean;
}
function getTopWebOverlay(): WebOverlayEntry | undefined {
const top = overlays.getTop();
return top ? focusEntries.get(top) : undefined;
}
function getFocusableElements(scope: HTMLElement): HTMLElement[] {
const selector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
return Array.from(scope.querySelectorAll<HTMLElement>(selector)).filter(
(element) =>
element.getAttribute("aria-hidden") !== "true" &&
!element.hasAttribute("hidden") &&
element.getClientRects().length > 0,
);
}
function focusFirstElement(scope: HTMLElement): void {
const firstMenuItem = scope.querySelector<HTMLElement>(
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
);
const first = firstMenuItem ?? getFocusableElements(scope)[0];
(first ?? scope).focus();
}
function handleWebOverlayFocus(event: FocusEvent): void {
const top = getTopWebOverlay();
const scope = top?.getScope();
if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return;
if (webOverlayFocusCheckQueued) return;
// React can autofocus a child before its parent scope ref attaches. Defer
// enforcement until the commit finishes so a newly mounted higher overlay
// can register without the previous scope stealing the requested focus.
webOverlayFocusCheckQueued = true;
queueMicrotask(() => {
webOverlayFocusCheckQueued = false;
const currentTop = getTopWebOverlay();
const currentScope = currentTop?.getScope();
if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement))
return;
focusFirstElement(currentScope);
});
}
/**
* Gives the top painted overlay first refusal on a key before app-wide shortcuts run.
* The window listener below is the fallback for keys nobody routes through the shortcut engine.
*/
function dispatchOverlayEntry(entry: WebOverlayEntry, event: KeyboardInputEvent): boolean {
// Composition keys belong to the active text field. A modal must not turn an
// IME candidate confirmation into an overlay shortcut before the browser has
// committed the composed text.
if (event.isComposing || event.key === "Process") return false;
const top = entry;
const scope = entry.getScope();
if (!scope) return top.getKeyHandler()(event);
if (top.manageFocus && event.key === "Tab") {
const focusable = getFocusableElements(scope);
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
const shouldWrapBackward = event.shiftKey && (!scope.contains(active) || active === first);
const shouldWrapForward = !event.shiftKey && (!scope.contains(active) || active === last);
if (shouldWrapBackward || shouldWrapForward) {
event.preventDefault();
event.stopPropagation();
(shouldWrapBackward ? last : first)?.focus();
if (!first) scope.focus();
return true;
}
}
if (!top.getKeyHandler()(event)) return false;
event.stopPropagation();
return true;
}
function handleWebOverlayKeyDown(event: KeyboardEvent): void {
if (overlays.dispatch(event)) event.stopImmediatePropagation();
}
function attachWebOverlayListeners(): void {
if (webOverlayListenersAttached) return;
window.addEventListener("keydown", handleWebOverlayKeyDown, true);
document.addEventListener("focusin", handleWebOverlayFocus, true);
webOverlayListenersAttached = true;
}
function detachWebOverlayListeners(): void {
if (!webOverlayListenersAttached || overlays.hasActive()) return;
window.removeEventListener("keydown", handleWebOverlayKeyDown, true);
document.removeEventListener("focusin", handleWebOverlayFocus, true);
webOverlayListenersAttached = false;
}
function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptions) => void {
const registration: OverlayEntry = {
getLayer: entry.getLayer,
getKeys: entry.getKeys,
onKeyDown: (event) => dispatchOverlayEntry(entry, event),
};
focusEntries.set(registration, entry);
const remove = overlays.register(registration);
attachWebOverlayListeners();
const focusFrame = window.requestAnimationFrame(() => {
const scope = entry.getScope();
if (
entry.manageFocus &&
getTopWebOverlay() === entry &&
scope &&
!scope.contains(document.activeElement)
) {
focusFirstElement(scope);
}
});
return (options) => {
window.cancelAnimationFrame(focusFrame);
remove();
detachWebOverlayListeners();
if (
entry.manageFocus &&
options?.restoreFocus !== false &&
entry.restoreFocus &&
document.contains(entry.restoreFocus)
) {
entry.restoreFocus.focus();
}
};
}
/**
* Registers a focus scope in the same relative layer model used for painting.
* Only the highest painted overlay receives keyboard input. By default, focus is trapped
* there and restored to the opener when it closes; hover surfaces preserve focus instead.
*/
export function useOverlayRegistration({
active,
layer,
keys,
onKeyDown,
manageFocus = true,
restoreFocusRef: preferredRestoreFocusRef,
}: OverlayRegistration) {
const scopeRef = useRef<HTMLElement | null>(null);
const keysRef = useRef(keys);
keysRef.current = keys;
const layerRef = useRef(layer);
const keyHandlerRef = useRef(onKeyDown);
const capturedRestoreFocusRef = useRef<HTMLElement | null>(null);
const removeEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null);
const detachedRemoveEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null);
const registeredRef = useRef(false);
const activeRef = useRef(active);
const wasActiveRef = useRef(false);
activeRef.current = active;
layerRef.current = layer;
keyHandlerRef.current = onKeyDown;
if (active && !wasActiveRef.current && typeof document !== "undefined") {
const requestedRestoreTarget = preferredRestoreFocusRef?.current;
if (requestedRestoreTarget instanceof HTMLElement) {
capturedRestoreFocusRef.current = requestedRestoreTarget;
} else {
capturedRestoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
}
wasActiveRef.current = active;
const syncRegistration = useCallback(() => {
const shouldRegister =
activeRef.current && typeof window !== "undefined" && typeof document !== "undefined";
if (!shouldRegister) {
const shouldRestoreFocus = !activeRef.current;
if (registeredRef.current || shouldRestoreFocus) {
removeEntryRef.current?.({ restoreFocus: shouldRestoreFocus });
}
registeredRef.current = false;
if (shouldRestoreFocus) {
removeEntryRef.current = null;
detachedRemoveEntryRef.current = null;
}
return;
}
if (registeredRef.current) return;
detachedRemoveEntryRef.current = null;
const entry: WebOverlayEntry = {
getLayer: () => layerRef.current,
getKeys: () => keysRef.current,
getScope: () => scopeRef.current,
getKeyHandler: () => keyHandlerRef.current,
restoreFocus: capturedRestoreFocusRef.current,
manageFocus,
};
removeEntryRef.current = addWebOverlay(entry);
registeredRef.current = true;
}, [manageFocus]);
const setScope = useCallback(
(node: unknown) => {
scopeRef.current =
typeof HTMLElement !== "undefined" && node instanceof HTMLElement ? node : null;
if (!scopeRef.current && activeRef.current) {
const removeEntry = removeEntryRef.current;
removeEntry?.({ restoreFocus: false });
detachedRemoveEntryRef.current = removeEntry;
removeEntryRef.current = null;
registeredRef.current = false;
return;
}
// Host refs attach before descendant layout effects and autofocus. Register
// here so the previous overlay cannot redirect that pending focus.
syncRegistration();
},
[syncRegistration],
);
useLayoutEffect(() => {
syncRegistration();
return () => {
const removeEntry = removeEntryRef.current ?? detachedRemoveEntryRef.current;
removeEntry?.();
removeEntryRef.current = null;
detachedRemoveEntryRef.current = null;
registeredRef.current = false;
};
}, [active, syncRegistration]);
return setScope;
}
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { createOverlayRegistry } from "./overlays";
function keyEvent(key: string) {
return {
key,
code: key,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
repeat: false,
isComposing: false,
preventDefault() {},
stopPropagation() {},
};
}
describe("overlay keyboard ownership", () => {
it("offers a key only to the highest layer and consumes it once without DOM focus", () => {
const registry = createOverlayRegistry();
const calls: string[] = [];
const remove = registry.register({
getLayer: () => 20,
getKeys: () => ["Escape"],
onKeyDown: (event) => {
calls.push(`modal:${event.key}`);
return true;
},
});
const removeMenu = registry.register({
getLayer: () => 30,
getKeys: () => ["Enter"],
onKeyDown: (event) => {
calls.push(`menu:${event.key}`);
return event.key === "Enter";
},
});
expect(registry.dispatch(keyEvent("Escape"))).toBe(false);
expect(calls).toEqual(["menu:Escape"]);
expect(registry.getKeys()).toEqual(["Enter"]);
removeMenu();
expect(registry.dispatch(keyEvent("Escape"))).toBe(true);
expect(calls).toEqual(["menu:Escape", "modal:Escape"]);
remove();
expect(registry.dispatch(keyEvent("Escape"))).toBe(false);
});
});
describe("overlay registration lifecycle", () => {
it("uses the newest registration at equal layers and ignores composition", () => {
const registry = createOverlayRegistry();
const calls: string[] = [];
const first = registry.register({
getLayer: () => 20,
getKeys: () => ["Escape"],
onKeyDown: () => {
calls.push("first");
return true;
},
});
const second = registry.register({
getLayer: () => 20,
getKeys: () => ["Enter"],
onKeyDown: () => {
calls.push("second");
return true;
},
});
expect(registry.dispatch({ ...keyEvent("Enter"), isComposing: true })).toBe(false);
expect(calls).toEqual([]);
expect(registry.dispatch(keyEvent("Enter"))).toBe(true);
expect(calls).toEqual(["second"]);
second();
expect(registry.dispatch(keyEvent("Escape"))).toBe(true);
expect(calls).toEqual(["second", "first"]);
first();
});
it("notifies subscribers when top key ownership changes", () => {
const registry = createOverlayRegistry();
const observed: string[][] = [];
const unsubscribe = registry.subscribe(() => observed.push([...registry.getKeys()]));
const release = registry.register({
getLayer: () => 20,
getKeys: () => ["Enter"],
onKeyDown: () => false,
});
release();
release();
unsubscribe();
expect(observed).toEqual([["Enter"], []]);
});
});
@@ -0,0 +1,53 @@
import type { KeyboardShortcutInput } from "../keyboard-shortcuts";
export interface KeyboardInputEvent extends KeyboardShortcutInput {
isComposing?: boolean;
preventDefault(): void;
stopPropagation(): void;
}
export interface OverlayEntry {
getLayer(): number;
getKeys(): readonly string[];
onKeyDown(event: KeyboardInputEvent): boolean;
}
export function createOverlayRegistry() {
const entries: OverlayEntry[] = [];
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
function getTop(): OverlayEntry | undefined {
return entries.reduce<OverlayEntry | undefined>(
(top, entry) => (!top || entry.getLayer() >= top.getLayer() ? entry : top),
undefined,
);
}
return {
getTop,
getKeys: () => getTop()?.getKeys() ?? [],
hasActive: () => entries.length > 0,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
register(entry: OverlayEntry) {
entries.push(entry);
notify();
return () => {
const index = entries.indexOf(entry);
if (index < 0) return;
entries.splice(index, 1);
notify();
};
},
dispatch(event: KeyboardInputEvent): boolean {
if (event.isComposing || event.key === "Process") return false;
if (!getTop()?.onKeyDown(event)) return false;
event.stopPropagation();
return true;
},
};
}
export const overlays = createOverlayRegistry();
@@ -0,0 +1,60 @@
import {
resolveKeyboardShortcut,
type ChordState,
type KeyboardShortcutContext,
type KeyboardShortcutMatch,
type ParsedShortcutBinding,
} from "../keyboard-shortcuts";
import type { KeyboardSourceInput } from "./sources/contract";
import type { createOverlayRegistry } from "./overlays";
interface KeyboardPipelineOptions {
getContext(): Omit<KeyboardShortcutContext, "focusScope">;
getBindings(): readonly ParsedShortcutBinding[];
isCapturing(): boolean;
dispatch(match: KeyboardShortcutMatch, input: KeyboardSourceInput): boolean;
overlays: ReturnType<typeof createOverlayRegistry>;
beforeResolve?(input: KeyboardSourceInput): boolean;
chordChanged?(previous: ChordState, next: ChordState, input: KeyboardSourceInput | null): void;
}
function emptyChord(): ChordState {
return { candidateIndices: [], step: 0, timeoutId: null };
}
export function createKeyboardPipeline(options: KeyboardPipelineOptions) {
let chord = emptyChord();
function reset() {
const previous = chord;
if (chord.timeoutId !== null) clearTimeout(chord.timeoutId);
chord = emptyChord();
options.chordChanged?.(previous, chord, null);
}
return {
dispose: reset,
keyDown(input: KeyboardSourceInput): boolean {
const event = input.event;
if (options.overlays.dispatch(event)) return true;
if (event.isComposing || event.key === "Process" || options.isCapturing()) return false;
if (options.beforeResolve && !options.beforeResolve(input)) return false;
const previous = chord;
const result = resolveKeyboardShortcut({
event,
context: { ...options.getContext(), focusScope: input.focusScope },
bindings: options.getBindings(),
chordState: chord,
onChordReset: reset,
});
chord = result.nextChordState;
options.chordChanged?.(previous, chord, input);
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
if (!result.match) return false;
if (!options.dispatch(result.match, input)) return false;
if (result.match.preventDefault) event.preventDefault();
if (result.match.stopPropagation) event.stopPropagation();
return true;
},
};
}
@@ -0,0 +1,6 @@
import { createHardwareKeyboardPresence } from "./hardware-presence";
import { getHardwareKeyboardPresent, subscribeHardwareKeyboardPresence } from "./sources/hardware";
export const hardwareKeyboardPresence = createHardwareKeyboardPresence({
read: getHardwareKeyboardPresent,
watch: subscribeHardwareKeyboardPresence,
});
@@ -0,0 +1,31 @@
import { createHardwareKeyboardPresence } from "./hardware-presence";
export function createWebHardwareKeyboardPresence(
pointer: MediaQueryList | null,
events: Window | null,
) {
let observedKeyboard = false;
return createHardwareKeyboardPresence({
read: () => observedKeyboard || pointer?.matches === true,
watch(changed) {
if (!events) return () => {};
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)) return;
observedKeyboard = true;
changed();
};
pointer?.addEventListener("change", changed);
events.addEventListener("keydown", onKeyDown, true);
return () => {
pointer?.removeEventListener("change", changed);
events.removeEventListener("keydown", onKeyDown, true);
};
},
});
}
const events = typeof window === "undefined" ? null : window;
const pointer =
events && typeof events.matchMedia === "function"
? events.matchMedia("(hover: hover) and (pointer: fine)")
: null;
export const hardwareKeyboardPresence = createWebHardwareKeyboardPresence(pointer, events);
@@ -0,0 +1,27 @@
import type { KeyboardFocusScope } from "../../actions";
import type { KeyboardInputEvent } from "../overlays";
export interface KeyboardSourceInput {
event: KeyboardInputEvent;
focusScope: KeyboardFocusScope;
captureFocusRestore(): void;
}
export interface KeyboardSourceHandlers {
keyDown(input: KeyboardSourceInput): void;
keyUp(event: KeyboardInputEvent): void;
blur(): void;
commandCenterOpen(): boolean;
}
export function nativeKeyboardInput(
event: KeyboardInputEvent,
focusScope: KeyboardFocusScope,
): KeyboardSourceInput {
return { event, focusScope, captureFocusRestore() {} };
}
import type { ChordState, ParsedShortcutBinding } from "../../keyboard-shortcuts";
export interface KeyboardPolicyInput {
bindings: readonly ParsedShortcutBinding[];
chordState?: ChordState;
isMac: boolean;
enabled: boolean;
}
@@ -0,0 +1,44 @@
import { resolveKeyboardFocusScope } from "../../focus-scope";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { keyBelongsToDomPopover } from "../dom-popover";
import type { KeyboardSourceHandlers } from "./contract";
export function attachDomKeyboardSource(handlers: KeyboardSourceHandlers): () => void {
const down = (event: KeyboardEvent) => {
if (document.visibilityState !== "visible" || keyBelongsToDomPopover(event)) return;
handlers.keyDown({
event: {
key: event.key,
code: event.code,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
repeat: event.repeat,
isComposing: event.isComposing || event.keyCode === 229,
preventDefault: () => event.preventDefault(),
stopPropagation: () => event.stopImmediatePropagation(),
},
focusScope: resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: handlers.commandCenterOpen(),
}),
captureFocusRestore() {
const target = event.target instanceof Element ? event.target : null;
const editable = target?.closest("textarea, input, [contenteditable='true']");
const focused = editable instanceof HTMLElement ? editable : document.activeElement;
setCommandCenterFocusRestoreElement(focused instanceof HTMLElement ? focused : null);
},
});
};
const up = (event: KeyboardEvent) => handlers.keyUp(event);
window.addEventListener("keydown", down, true);
window.addEventListener("keyup", up, true);
window.addEventListener("blur", handlers.blur);
document.addEventListener("visibilitychange", handlers.blur);
return () => {
window.removeEventListener("keydown", down, true);
window.removeEventListener("keyup", up, true);
window.removeEventListener("blur", handlers.blur);
document.removeEventListener("visibilitychange", handlers.blur);
};
}
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import {
attachElectronBrowserSource,
buildBrowserKeyboardPolicy,
parseBrowserShortcutInput,
shouldPublishBrowserShortcutPolicy,
} from "./shortcuts";
import { buildEffectiveBindings, resolveKeyboardShortcut } from "../../keyboard/keyboard-shortcuts";
} from "./electron-browser";
import { buildEffectiveBindings, resolveKeyboardShortcut } from "../../keyboard-shortcuts";
describe("buildBrowserKeyboardPolicy", () => {
it("publishes only chord starts while no browser chord is pending", () => {
@@ -13,7 +14,11 @@ describe("buildBrowserKeyboardPolicy", () => {
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+F12 Ctrl+F11",
});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
const policy = buildBrowserKeyboardPolicy({
bindings,
isMac: false,
usesDesktopBindings: true,
});
expect(policy.prefixes).toContainEqual({
alt: false,
@@ -51,7 +56,7 @@ describe("buildBrowserKeyboardPolicy", () => {
bindings,
chordState: { candidateIndices: [chordIndex], step: 1, timeoutId: null },
isMac: false,
isDesktop: true,
usesDesktopBindings: true,
});
expect(policy.prefixes).toEqual([
@@ -85,7 +90,7 @@ describe("buildBrowserKeyboardPolicy", () => {
context: {
commandCenterOpen: false,
focusScope: "browser",
isDesktop: true,
usesDesktopBindings: true,
isMac: false,
},
chordState: { candidateIndices: [chordIndex], step: 1, timeoutId: null },
@@ -101,7 +106,11 @@ describe("buildBrowserKeyboardPolicy", () => {
"settings-toggle-ctrl-comma-non-mac": "Ctrl+F10 F9",
});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
const policy = buildBrowserKeyboardPolicy({
bindings,
isMac: false,
usesDesktopBindings: true,
});
expect(policy.prefixes).not.toContainEqual({
alt: false,
@@ -125,7 +134,7 @@ describe("buildBrowserKeyboardPolicy", () => {
});
expect(
buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }).prefixes,
buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true }).prefixes,
).toContainEqual({
alt: false,
code: "KeyY",
@@ -141,7 +150,11 @@ describe("buildBrowserKeyboardPolicy", () => {
"workspace-tab-close-current-ctrl-w-non-mac": "Ctrl+Y",
});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
const policy = buildBrowserKeyboardPolicy({
bindings,
isMac: false,
usesDesktopBindings: true,
});
expect(policy.prefixes).not.toContainEqual({
alt: false,
@@ -164,7 +177,7 @@ describe("buildBrowserKeyboardPolicy", () => {
it("leaves macOS browser back and forward shortcuts in the guest", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true });
for (const code of ["BracketLeft", "BracketRight"]) {
expect(policy.prefixes).not.toContainEqual({
@@ -180,7 +193,7 @@ describe("buildBrowserKeyboardPolicy", () => {
it("marks editable-only exclusions for enforcement inside the guest", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true });
expect(policy.prefixes).toContainEqual({
alt: false,
@@ -194,7 +207,11 @@ describe("buildBrowserKeyboardPolicy", () => {
it("does not publish plain browser keys", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
const policy = buildBrowserKeyboardPolicy({
bindings,
isMac: false,
usesDesktopBindings: true,
});
expect(policy.prefixes).not.toContainEqual({
alt: false,
@@ -214,7 +231,7 @@ describe("buildBrowserKeyboardPolicy", () => {
it("publishes Cmd+B with its logical key for non-QWERTY layouts", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true });
expect(policy.prefixes).toContainEqual({
alt: false,
@@ -228,7 +245,7 @@ describe("buildBrowserKeyboardPolicy", () => {
it("publishes the physical code needed for macOS Option shortcuts", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true });
expect(policy.prefixes).toContainEqual({
alt: true,
@@ -320,3 +337,49 @@ describe("parseBrowserShortcutInput", () => {
expect(parseBrowserShortcutInput(payload)).toBeNull();
});
});
describe("Electron guest source through shared resolution", () => {
it("replays guest input through user rebinds and preserves focus restoration", async () => {
const { createKeyboardPipeline } = await import("../pipeline");
const { createOverlayRegistry } = await import("../overlays");
const actions: string[] = [];
const restored: string[] = [];
let receive: (payload: unknown) => void = () => {};
const pipeline = createKeyboardPipeline({
getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }),
getBindings: () => buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }),
isCapturing: () => false,
overlays: createOverlayRegistry(),
dispatch: (match, input) => {
actions.push(match.action);
input.captureFocusRestore();
return true;
},
});
const detach = attachElectronBrowserSource(
{
subscribe(handler) {
receive = handler;
return () => {
receive = () => {};
};
},
captureFocusRestore: (id) => restored.push(id),
},
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
);
receive({
browserId: "tab-1",
key: "j",
code: "KeyJ",
alt: false,
control: false,
meta: true,
shift: false,
});
expect(actions).toEqual(["command-center.toggle"]);
expect(restored).toEqual(["tab-1"]);
detach();
pipeline.dispose();
});
});
@@ -3,8 +3,8 @@ import {
matchesKeyboardShortcutContext,
type KeyboardShortcutInput,
type ParsedShortcutBinding,
} from "../../keyboard/keyboard-shortcuts";
import type { KeyCombo } from "../../keyboard/shortcut-string";
} from "../../keyboard-shortcuts";
import type { KeyCombo } from "../../shortcut-string";
export interface BrowserShortcutPrefix {
alt: boolean;
@@ -32,7 +32,7 @@ interface BrowserShortcutPolicyInput {
bindings: readonly ParsedShortcutBinding[];
chordState?: ChordState;
isMac: boolean;
isDesktop: boolean;
usesDesktopBindings: boolean;
}
export function shouldPublishBrowserShortcutPolicy(input: {
@@ -149,7 +149,7 @@ function buildBrowserShortcutPrefixes(input: BrowserShortcutPolicyInput): Browse
const prefixes = new Map<string, BrowserShortcutPrefix>();
const context = {
isMac: input.isMac,
isDesktop: input.isDesktop,
usesDesktopBindings: input.usesDesktopBindings,
focusScope: "browser" as const,
commandCenterOpen: false,
};
@@ -209,3 +209,26 @@ export function buildBrowserKeyboardPolicy(
}
return { menuPrefixes, prefixes };
}
interface ElectronBrowserKeySource {
subscribe(handler: (payload: unknown) => void): (() => void) | Promise<() => void>;
captureFocusRestore(browserId: string): void;
}
export function attachElectronBrowserSource(
source: ElectronBrowserKeySource,
handlers: import("./contract").KeyboardSourceHandlers,
): () => void {
const subscription = source.subscribe((payload) => {
const input = parseBrowserShortcutInput(payload);
if (!input) return;
handlers.keyDown({
event: { ...input, preventDefault() {}, stopPropagation() {} },
focusScope: "browser",
captureFocusRestore: () => source.captureFocusRestore(input.browserId),
});
});
return () => {
if (typeof subscription === "function") subscription();
else void subscription.then((dispose) => dispose());
};
}
@@ -0,0 +1,29 @@
import { requireNativeModule } from "expo-modules-core";
import type { HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
setOverlayKeys(keys: readonly string[]): void;
addListener(
event: "onHardwareKeyboardShortcut",
handler: (event: HardwareKeyboardEvent) => void,
): { remove(): void };
}
const module = requireNativeModule<HardwareKeyboardModule>("PaseoHardwareKeyboard");
export function subscribeHardwareKeyboard(
handler: (event: HardwareKeyboardEvent) => void,
): () => void {
const subscription = module.addListener("onHardwareKeyboardShortcut", handler);
return () => subscription.remove();
}
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
}
export function subscribeHardwareKeyboardPresence(changed: () => void): () => void {
const subscription = module.addListener("onHardwareKeyboardPresenceChanged", changed);
return () => subscription.remove();
}
@@ -0,0 +1,29 @@
import { requireNativeModule } from "expo-modules-core";
import type { HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
setOverlayKeys(keys: readonly string[]): void;
addListener(
event: "onHardwareKeyboardShortcut",
handler: (event: HardwareKeyboardEvent) => void,
): { remove(): void };
}
const module = requireNativeModule<HardwareKeyboardModule>("PaseoHardwareKeyboard");
export function subscribeHardwareKeyboard(
handler: (event: HardwareKeyboardEvent) => void,
): () => void {
const subscription = module.addListener("onHardwareKeyboardShortcut", handler);
return () => subscription.remove();
}
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
}
export function subscribeHardwareKeyboardPresence(changed: () => void): () => void {
const subscription = module.addListener("onHardwareKeyboardPresenceChanged", changed);
return () => subscription.remove();
}
@@ -0,0 +1,18 @@
import type { KeyboardShortcutInput } from "../../keyboard-shortcuts";
export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
isComposing: boolean;
phase: "down" | "up";
}
export function subscribeHardwareKeyboard(
_handler: (event: HardwareKeyboardEvent) => void,
): () => void {
return () => {};
}
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
export function getHardwareKeyboardPresent(): boolean {
return false;
}
export function subscribeHardwareKeyboardPresence(_changed: () => void): () => void {
return () => {};
}
@@ -0,0 +1,11 @@
import type { KeyboardSourceHandlers } from "./contract";
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
import { attachHardwareKeyboardSource } from "./native";
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
return attachHardwareKeyboardSource(
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
handlers,
);
}
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
@@ -0,0 +1,32 @@
import { usesDesktopBindings } from "../conventions";
import { getDesktopHost } from "@/desktop/host";
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { buildBrowserKeyboardPolicy, attachElectronBrowserSource } from "./electron-browser";
import { attachDomKeyboardSource } from "./dom.web";
import type { KeyboardSourceHandlers } from "./contract";
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
const detachDom = attachDomKeyboardSource(handlers);
const host = getDesktopHost();
const subscribeGuest = host?.events?.on;
const detachGuest = subscribeGuest
? attachElectronBrowserSource(
{
subscribe: (handler) => subscribeGuest("browser-shortcut-input", handler),
captureFocusRestore: (browserId) =>
setCommandCenterFocusRestoreElement(getResidentBrowserWebview(browserId)),
},
handlers,
)
: () => {};
return () => {
detachDom();
detachGuest();
};
}
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
const policy = input.enabled
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
: { menuPrefixes: [], prefixes: [] };
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
}

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