Compare commits

...
Author SHA1 Message Date
Mohamed Boudra a7533e8330 fix(codex): honor full-access MCP elicitations
Codex's never-ask policy accepts empty MCP forms, but Paseo routed those app-server requests into its permission channel. Mirror that provider contract while declining data-bearing forms that Paseo cannot answer.
2026-08-10 17:58:56 +02:00
Mohamed Boudra a98d154b6b Search workspace files from the command center (#3059)
* feat(command-center): add workspace file search

Use the existing workspace-aware fuzzy search so file lookup follows the file explorer's ignore and hidden-directory rules. Cmd/Ctrl+P opens a removable Files scope while the normal command center also includes file matches.

* fix(command-center): stabilize file search results

* fix(shortcuts): preserve project picker command

* fix(command-center): keep file results while searching

* feat(command-center): integrate the files scope into the search row

The scope was a 24px pill with 12px text sitting inside the 16px input line, so
it read as a badge dropped into the query rather than the scope of the search.

Make it a leading cell of the search row instead: folder icon, input-sized label,
and a hairline divider before the caret. It stretches to the row's cross size and
bleeds through the header padding with negative margins, so its height is driven
entirely by the input and the panel's own radius clips its corner. Toggling the
scope cannot resize the header, which the new spec pins.

* fix(command-center): restore the files scope as a chip

The previous commit turned the scope into a flush leading cell of the header. Put
the chip back: rounded, bordered, folder icon and 14px label, no divider.

It stretches to the row instead of carrying a height, so it is exactly as tall as
the input's line box and cannot resize the header.

* refactor(shortcuts): regroup and reorder the shortcut help sections

Panels was a junk drawer: the command center, file search, the shortcuts sheet
itself, new terminal, the sidebars, settings, focus mode and theme cycling. The
two shortcuts people reach for most, Cmd+K and Cmd+P, sat inside it below three
other sections. Navigation held both workspace and tab movement.

Sections are now General, Projects & Workspaces, Tabs & Panes, Layout, and Agent
Input. General leads with the command center and file search. Tab movement joins
Tabs & Panes, new terminal joins the tabs it creates, and Layout keeps only the
sidebar and focus toggles.

Row order is now data rather than a side effect of binding-definition order, so
the row someone opens the sheet to find sits at the top of its section. A test
fails when a new shortcut has no place in that order instead of letting it sink
to the bottom unnoticed.

* Keep file search failures in view
2026-08-10 17:02:35 +02:00
Mohamed Boudra 42f1d0b289 docs(hub): align public docs with v0.3 (#3152)
* docs(hub): align public docs with v0.3

Bring the public workflow, API, authority, activity, and self-hosting guidance in line with the released Hub contracts. Clarify the public HTTPS boundary required by provider events while keeping Hub and PostgreSQL private.

* docs(hub): clarify daemon logout scope

* docs(hub): restore Compose path and simplify TLS guidance

* docs(hub): link the single-repo-team-bot example
2026-08-10 16:13:51 +02:00
Mohamed Boudra 48e65b407f Replace Parcel Watcher with Custom Watcher (#3107)
* fix(server): replace wedging file observation

Native watcher teardown could block the daemon main thread during workspace archive. Own recursive observation behind an awaited lifecycle boundary, retain an isolated comparison harness, and gate correctness, resources, and sustained CPU across platforms.

* test(server): accept root watcher evidence

Native recursive watchers may coalesce ignored descendant changes into a root event. Assert that ignored paths stay excluded while a nonignored sentinel proves delivery remains live.

* test(server): preserve watcher comparison evidence

Record Parcel delivery misses without weakening the production observer gate, and allow the Windows stress test to use its declared polling window.

* fix(server): reconcile native watcher omissions

Back macOS and Windows recursive watching with a bounded namespace inventory so coalesced create and delete events converge without unbounded rescans.

* test(server): compare worktree filesystem identity

Windows can return the same worktree through 8.3 and long path spellings. Preserve cardinality and branch checks while comparing the real path.

* perf(server): bound native inventory audits

Reduce full native inventory cadence while preserving a six-second convergence bound, and gate setup readiness and event-loop p99 against platform-appropriate budgets.

* build: refresh Nix npm dependency hash

* perf(server): scope native file reconciliation

* perf(server): defer native safety audits until quiet

* fix(server): move native safety deadline during audits

* perf(server): audit native inventories after settled churn

* fix(server): reconcile native root evidence promptly

* perf(server): deduplicate native audit scheduling

* perf(server): coalesce native event clusters

* perf(server): suppress fresh native change scans

* test(server): separate burst recovery from sustained churn

* test(server): stabilize observer performance gates

* perf(server): skip fresh native change scans

* fix(server): preserve nested native change audits

* perf(server): coalesce native repair scans

* fix(server): reconcile native directory moves promptly

* test(server): bound observer burst repair cost

* refactor(server): own file observer lifecycle

* build: refresh Nix npm dependency hash

* ci: fold observer coverage into server suite

* fix(server): await file observer shutdown
2026-08-10 13:43:48 +02:00
Mohamed Boudra 7954babca6 Publish the TypeScript SDK and integration guides (#3141)
* feat(client): publish the TypeScript SDK surface

Make the high-level client API usable for external integrations and document complete agent, workspace, provider, event, and recipe flows.

* fix(client): correlate provider snapshots by daemon cwd

Return the daemon's canonical snapshot identity so waitForReady can match updates for relative, tilde, and otherwise non-canonical paths without duplicating filesystem rules in the SDK.

* test(server): make snapshot cwd assertion portable

* fix(client): gate canonical provider snapshot waits

Older daemons do not expose the canonical snapshot identity needed to correlate workspace updates safely. Require the advertised capability and fail with an update-host message instead of guessing across workspaces.
2026-08-10 13:22:20 +02:00
Mohamed Boudra 05a0cabc07 Deploy multi-file Hub workflows (#3128)
* feat(hub): deploy canonical workflow bundles

Keep CLI deployment aligned with Hub's authored bundle boundary so validation and activation receive the same deterministic source files. Public documentation now teaches that boundary as the only configuration format.

* test(hub): assert validation diagnostics directly

* refactor(hub): deepen HTTP client module

* feat(hub): validate named agent configurations

* docs(hub): align Claude mode example
2026-08-10 12:32:59 +02:00
b7f137c2a0 Render SVG project icons on native and fall back for ICO (#2579)
* Render SVG project icons on native and fall back for ICO

React Native's <Image> silently decodes neither SVG nor ICO, so any
project whose discovered icon was one of those formats showed a blank
slot on iOS/Android while web/Electron rendered it fine. Route SVG data
URIs through react-native-svg on native and let ICO fall through to the
initial-letter avatar instead of rendering nothing.

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

* Import Buffer explicitly so SVG icons actually decode on device

React Native has no global Buffer and the app installs no global polyfill,
so projectIconSvgXml threw ReferenceError inside its try/catch and returned
null on every SVG — the exact blank-slot bug the previous commit meant to
fix. Vitest runs in Node where Buffer is global, which is why the tests
passed anyway; the new regression test deletes globalThis.Buffer to keep
the import honest.

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

* Prefer renderable icon formats and unwrap PNG-bearing ICOs

favicon.ico ranked first in ICON_PATTERNS, so any repo with a standard
public/ layout served ICO — which native <Image> cannot decode — even when
an SVG or PNG sat in the same directory. Rank SVG and PNG above ICO for
each name, sniff magic bytes instead of trusting the extension (renamed
PNGs shipped as image/x-icon are common), and extract the largest
PNG-encoded frame from real ICO containers so native clients get a
decodable image instead of falling back to the letter avatar.

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

* Render SVG icons with CSS support and fall back on parse failure

SvgXml ignores <style> blocks, which favicon SVGs commonly use, leaving
paths with a default black fill — invisible on dark themes. SvgCss inlines
the styles before rendering. Pass the letter avatar as the fallback so a
malformed SVG degrades to it instead of an empty slot, and fold the
workspace setup dialog's hand-rolled icon cascade into ProjectIconView so
the format handling lives in one place.

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

* Take the platform as an explicit parameter in icon source helpers

The tests previously pinned isNative to true with a module-level vi.mock,
which coupled them to import initialization and left the web pass-through
paths uncovered. Defaulting an explicit `native` parameter to the real
constant keeps call sites unchanged while letting tests exercise both
variants directly.

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

* refactor(app): isolate native project icon rendering

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-10 12:01:09 +02:00
Mohamed Boudra 02c4bc76a6 Keep Add Project method selection path-free (#3099)
* fix(app): remove search from project method selection

The fixed method list should not accept text because it can be mistaken for the directory path step that follows.

* fix(app): preserve native project picker keys

Keep hardware-keyboard delivery owned by the Add Project flow even when method selection has no visible text field.

* test(app): update add project overlay expectation
2026-08-09 21:46:58 +02:00
Mohamed Boudra ccb4e444ed Rework Hub workflow docs around explicit delivery and GitHub authority (#3086)
* docs(hub): name the Hub tools in workflow prompts

Every Hub workflow example now tells the agent which tool to call:
hub.reply for the user-facing response, hub.finish_execution to
complete the step and record structured output. Granting a capability
through allow_outputs makes the tool available; it does not make an
agent use it, and an agent that is not told answers in its own
transcript and leaves the step to its idle timeout. Explicit
instructions are what worked against real agents.

Classifier and other intermediate steps name hub.finish_execution
alone, so the docs do not imply every step must reply.

Drops the github.reply allow_outputs blocks from the GitHub examples.
The GitHub provider registers no output capabilities, so that
declaration never materializes a tool; those agents comment through
their scoped GH_TOKEN with gh, which the reference and trigger pages
already said. The read-only answer branch has no network authority for
that, so it only finishes.

Also quotes the "Request: ${{ paseo.prompt }}" prompt block in the
reference, which was invalid YAML as written.

* docs(hub): wrap triggering input in its own prompt block

Every Hub YAML example now ends its prompt with the raw triggering
message wrapped in <user-prompt> tags, as a separate block after the
workflow instructions. The agent can see where your instructions stop
and the request starts, which matters most on the steps that read
external Slack, Discord, and GitHub text.

The first workflow explains the convention in two sentences and points
at Hub security for the controls that actually limit an agent. Tags
label the input; they are not containment, and the docs do not claim
otherwise.

Replaces the earlier quoting fix in the prompt-blocks reference: the
message now lives in a block scalar, so the colon needs no quoting.

* docs: base worktree examples on origin/main

Every branch-off example now passes a remote-tracking ref. Paseo
fetches remote refs in the background, so origin/main is current while
the local main is whatever was last pulled, and base resolution tries
refs/heads first: an unqualified main silently branches off stale
history. The gotcha is explained once, next to the first branch-off
example in the worktrees guide; the other pages just use the ref, and
the Hub environment reference links there.

Also points the safety-gate classifier at the structured-output
section, which shows the output wrapper the finish call takes.

* docs(hub): configure Codex read-only through provider options

Hub examples described a read-only Codex step as mode: read-only. That
Paseo mode preset pairs the read-only sandbox with approval_policy
on-request, so an unattended Hub step stops to ask a question nobody is
there to answer. It is also not one of the modes the Codex provider
advertises.

Every read-only step now uses the provider-native passthrough proven by
the real-provider evidence in #3025: approval_policy never,
sandbox_mode read-only, web_search disabled. The security guide already
used this block; the workflow guide, examples, and reference now agree
with it, and the reference says plainly that a Paseo mode ID is a
separate surface from these options.

mode: full-access is unchanged. It maps to approval_policy never, so it
does not stall an unattended step.

* docs(hub): show a classifier choosing the repository and the model

Both routing jobs were only documented as caller-supplied inputs, so
there was no example of the thing people actually want: one read-only
classifier that reads the request, picks the repository it belongs to
and the model it needs, and hands both to a worker. Replaces the
caller-only model example, which the new one covers through its
override values.

The two axes are not symmetric and the example says so. A model is a
value, so values.selected_model goes straight into agent.model. An
environment cannot come from an expression at all: the compiler accepts
a literal name or paseo.inputs.<name> and nothing else, so each
repository needs its own environment and its own conditional step. One
classifier, exactly one worker.

Corrects the routing section, which listed environment among the fields
an agent output may select. It never could.

Also puts a blank line between adjacent steps and environments in the
deadline and repository-routing examples, and bases the daemon worktree
example on origin/main, which the earlier sweep missed.

* fix(website): keep development requests local

Canonical host redirects were applied to LAN-addressed development traffic, including inspector WebSockets. The redirected socket retained the Vite port and caused the development proxy to terminate when the public host timed out.

* docs(hub): order the workflow scenarios from simple to hard

The Hub pages were arranged by the shape of the configuration schema
rather than by what a reader is trying to do, which showed up three
ways: the overview opened on the storage hierarchy, workflow
configurations were split between workflows.md and a separate examples
page, and the examples that existed started with the hardest one.

workflows.md is now a ladder. One Slack trigger answering in a thread
stays inline as the first workflow, and nine further scenarios sit
under it as collapsible blocks, each a complete configuration adding
one idea to the one above: full access, repeated replies, a GitHub
trigger, two ordered steps, a typed input, two triggers, a classifier
choosing a model, a classifier choosing a repository, and last the
override composed with ??. configuration/examples.md is removed and its
configurations live there now.

Two claims in the old material were wrong and are corrected. Nothing
carries between steps except a declared output, so the planning
scenario passes its plan through output.schema instead of telling the
second agent to follow a plan it cannot see, and a worktree environment
creates a worktree per execution rather than sharing one. GH_TOKEN
authenticates the gh CLI but does not configure git, so the pages no
longer imply a step can push without a commit identity and credential
helper on the daemon host.

The overview drops the organization/connections/daemons/projects tree,
which concepts.md already owns, in favour of the smallest configuration
that does something. Hosting is now a link from the product pages
rather than a section in them.

* docs(hub): make GitHub access an explicit step grant

GitHub authority gets its own page. A step holds a token only when it
declares a github block — connection, repositories, permissions,
duration — and Hub supplies GH_TOKEN plus environment-only git
configuration, revoked when the step ends. No credential is ever
implied by the trigger source.

Rewrites the issue-to-pull-request scenario onto the block, repoints
the trigger, reference, FAQ, and concepts pages, and sweeps the Hub
pages for announcing sentences and compressed paragraphs.

* docs(hub): lighten dense reference prose

Restructure paragraph walls into short paragraphs, lists, and code
blocks: logout behavior in daemons, deploy paths and precedence in
configuration, install limits and dispatch fields in the API page,
required outputs and partial resolution in the reference, and the
policy translation and secrets guidance in security. Fixes two
remaining trigger-derived credential claims in triggers/index.

* fix(nix): update npm dependency hash

* docs(hub): document shared prompts and authority defaults
2026-08-09 16:59:00 +02:00
paseo-ai[bot] b0a7d493aa fix: update lockfile signatures and Nix hash [skip ci] 2026-08-09 11:39:14 +00:00
Mohamed Boudra bfec7ac3ad chore(release): cut 0.3.1 2026-08-09 13:30:33 +02:00
Mohamed Boudra 5269c7b592 docs(release): prepare v0.3.1 2026-08-09 13:28:13 +02:00
Mohamed Boudra 05dc2022c8 Keep shared-worktree fetches proportional to ref changes (#3078)
* fix(server): bound shared-worktree fetch refreshes

* fix(server): preserve buffered git metadata events

* fix(server): cover remote ref selection changes

* fix(server): preserve narrow fetch echo routing

* fix(server): preserve ref updates during refresh

Keep normal refresh requeue semantics for watcher events that arrive while a ref-derived calculation is active, so a second same-ref movement cannot be mistaken for the original fetch echo.

* fix(server): narrow external fetch ref updates

Use authoritative fetch boundaries to separate watcher echoes from outside events, and retain remote ref names so packed-to-loose materialization from other Git clients stays narrow without weakening add/delete fallbacks.
2026-08-09 12:51:00 +02:00
Mohamed BoudraandChristoph Leiter c0daf8e066 feat(app): add running status ring and lighten status dots
Keep the animated ring phase-locked across hosts and derive its knockout surface from each host's actual interaction state. Align filled status circles at 6px while preserving the larger needs-input alert.

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-08-09 11:33:58 +02:00
Mohamed Boudra 6ae6231d1f fix(app): prevent inline code from obscuring text
Explicit inline-code line height creates oversized painted backgrounds when mixed markdown wraps on native. Let native font metrics determine the span height so adjacent lines remain visible.
2026-08-09 11:33:58 +02:00
Mohamed Boudra c77a6761af Treat unpublished desktop updates as unavailable (#3076)
* fix(desktop): treat unpublished updates as unavailable

GitHub releases can become visible before their platform updater manifests finish publishing. Treat that specific upstream condition as an unavailable update while preserving Paseo reporting for genuine updater failures.

* refactor(desktop): inject updater runtime dependency

Exercise updater event and rejection behavior through a typed fake at the runtime boundary instead of coordinating a hoisted module singleton in tests.

* fix(desktop): preserve lazy updater initialization

* revert(desktop): drop updater runtime refactor
2026-08-09 11:20:16 +02:00
Mohamed Boudra fd9cc5d693 Restore macOS direct LAN connections (#3071)
* fix(desktop): restore direct LAN connections

Desktop 0.3.0 routed remote WebSockets through the Electron main process, causing previously working macOS LAN connections to fail with EHOSTUNREACH. Revert custom direct headers and restore the renderer WebSocket path used before the regression.

* fix(app): replace host connections by identity

Keep one connection per stable ID when TLS or password settings change. This preserves the current-main upsert invariant while the custom-header rollback removes the unrelated transport feature.
2026-08-09 15:32:31 +08:00
Mohamed Boudra 7761d122d8 Stabilize async server lifecycle tests (#3060)
* test(server): stabilize async lifecycle coverage

* test(server): name teardown dependencies
2026-08-09 14:33:38 +08:00
01a1d3b2b6 Recover daemon workers stalled by workspace Git pressure (#3033)
* fix(server): bound workspace git pipelines

* fix(server): bound workspace git watcher setup

* fix(server): recover unresponsive daemon workers

* fix(server): tolerate transient daemon heartbeat delays

A five-second watchdog threshold can replace a healthy worker during a short event-loop delay. Keep recovery bounded while allowing a 15-second heartbeat gap, and preserve process-level coverage for workers that ignore graceful termination.

* test(server): normalize workspace fixture paths

Workspace Git entry points resolve paths before invoking dependencies. Keep admission-control fixtures keyed by the same canonical paths so their assertions and deferred gates work on Windows.

---------

Co-authored-by: Timi Ajiboye <trypio00@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-09 03:01:34 +02:00
Mohamed Boudra 3bca9b7a92 fix(app): preserve selected list markers when copying (#3055)
Mouse drags start and end inside rendered list items, so the clipboard serializer dropped item semantics even when the visible marker was selected. Treat the marker as explicit selection intent while keeping text-only selections unmarked.
2026-08-09 08:15:45 +08:00
Mohamed Boudra 8ffe7c75d5 Keep the daemon responsive when file watching fails (#3056)
* fix(server): keep watcher failures from stalling the daemon

Native watcher teardown can block the Node main thread when an error overlaps subscription lifecycle work. Reduce lifecycle churn, abandon errored subscriptions into bounded polling, recover only after setup settles, and expose error callback frequency in runtime metrics.

* fix(server): keep watcher recovery bounded
2026-08-09 02:14:00 +02:00
Christoph Leiter 74cf1931b1 fix(shortcuts): show effective bindings in the cheat sheet (#2985)
Render and search the shortcut chord that actually fires, including rebinding default-only display tokens such as question mark and index wildcards. Unassigned rows show no empty badge, and named-key overrides use the shared display vocabulary.
2026-08-09 01:56:32 +02:00
239 changed files with 13955 additions and 5119 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## 0.3.1 - 2026-08-09
### Added
- Unassign keyboard shortcuts and see the effective bindings in shortcut help ([#2510](https://github.com/getpaseo/paseo/pull/2510) by [@cleiter](https://github.com/cleiter), [#2985](https://github.com/getpaseo/paseo/pull/2985) by [@cleiter](https://github.com/cleiter))
- Choose a Pure black theme ([#3012](https://github.com/getpaseo/paseo/pull/3012) by [@shrimpwtf](https://github.com/shrimpwtf))
### Improved
- Running agents use a clearer animated status ring ([c0daf8e](https://github.com/getpaseo/paseo/commit/c0daf8e066e0d7379723a2a6ddea20afc71ead20) by [@cleiter](https://github.com/cleiter))
- Workspace Git updates stay responsive under file-watcher failures, heavy Git activity, and large shared-worktree repositories ([#3056](https://github.com/getpaseo/paseo/pull/3056), [#3033](https://github.com/getpaseo/paseo/pull/3033) by [@dwyanewang](https://github.com/dwyanewang), [#3078](https://github.com/getpaseo/paseo/pull/3078))
- Kimi usage refreshes correctly and shows five-hour limits and reasoning levels ([#2743](https://github.com/getpaseo/paseo/pull/2743) by [@UnbrokenHunter](https://github.com/UnbrokenHunter))
- Resize the desktop sidebar with touch ([#2953](https://github.com/getpaseo/paseo/pull/2953) by [@colonelpanic8](https://github.com/colonelpanic8))
### Fixed
- Direct LAN connections work again on macOS ([#3071](https://github.com/getpaseo/paseo/pull/3071))
- Desktop update checks no longer report an error while platform downloads are still publishing ([#3076](https://github.com/getpaseo/paseo/pull/3076))
- Copied lists keep their markers across selection boundaries ([#3055](https://github.com/getpaseo/paseo/pull/3055))
- Switching hosts keeps the active project selected ([#3051](https://github.com/getpaseo/paseo/pull/3051))
- New Workspace keeps project and isolation controls inside the composer ([#3050](https://github.com/getpaseo/paseo/pull/3050))
- Session rename is reachable from the mobile sessions menu ([#2924](https://github.com/getpaseo/paseo/pull/2924) by [@kaspesi](https://github.com/kaspesi))
- Desktop notification sounds play again ([#2582](https://github.com/getpaseo/paseo/pull/2582) by [@gstamp](https://github.com/gstamp))
- Codex slash commands omit disabled skills ([#2759](https://github.com/getpaseo/paseo/pull/2759) by [@jasonhnd](https://github.com/jasonhnd))
- Exact ignored file paths resolve correctly ([#3042](https://github.com/getpaseo/paseo/pull/3042) by [@cleiter](https://github.com/cleiter))
- Claude question notifications summarize the requested input ([#2925](https://github.com/getpaseo/paseo/pull/2925) by [@SihyeonJeon](https://github.com/SihyeonJeon))
- The crash screen stays readable and keeps its retry action reachable ([#3043](https://github.com/getpaseo/paseo/pull/3043))
## 0.3.0 - 2026-08-08
### Added
+1
View File
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
+25 -1
View File
@@ -105,7 +105,7 @@ Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo run --provider codex/gpt-5.5 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
@@ -117,6 +117,30 @@ paseo --host workstation.local:6767 run "run the full test suite"
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## TypeScript SDK
Build issue integrations, dashboards, and orchestration services with `@getpaseo/client`:
```ts
import { createPaseoClient } from "@getpaseo/client";
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
await client.connect();
const agent = await client.agents.create({
config: { provider: "codex/gpt-5.5" },
cwd: "/Users/me/dev/storefront",
prompt: "Review the current diff and name the riskiest change.",
});
const result = await agent.waitForFinish();
console.log(result.lastMessage);
await client.close();
```
See the [SDK quickstart](https://paseo.sh/docs/sdk/quickstart), [recipes](https://paseo.sh/docs/sdk/recipes), and [API reference](https://paseo.sh/docs/sdk/reference).
## Skills
Skills teach your agent to use Paseo to orchestrate other agents.
+1 -1
View File
@@ -223,7 +223,7 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: pushed away from the surface rather than toward it (darker in light, lighter in dark) and at 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's lightness the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. All four move together; regenerate the set, never one hue.
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
+19
View File
@@ -0,0 +1,19 @@
# File observation
Use `packages/server/src/server/file-observer` for recursive filesystem observation. Create one observer service at the owning service boundary, subscribe roots through it, and close it during owner shutdown. Keep platform watchers, directory discovery, exclusion reconciliation, event batching, failure cleanup, diagnostics, and teardown inside the module. A consumer supplies a root and excluded subtrees; it must not branch on the platform or manage child watchers.
Do not replace the Linux implementation with `fs.watch({ recursive: true })`. Node 22 implements Linux recursion in JavaScript by walking the tree and watching every file and directory. Paseo's directory-only Linux watcher already exhausted resources before ignored-tree pruning and a watcher cap were added in [#794](https://github.com/getpaseo/paseo/pull/794).
Closing an observation must remain safe after its root is renamed or removed. Workspace archive removes owned worktrees while releasing observation references, so teardown cannot assume the watched path still exists.
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
+5
View File
@@ -37,6 +37,11 @@ Each provider definition owns its option schema and exact MCP preapproval mappin
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
Codex **Full Access** sends `approvalPolicy: "never"` and `danger-full-access` to the app server.
If a permission-only MCP form still reaches the provider, Paseo accepts it when its schema has no
properties. Forms with fields and URL/auth flows are declined because Paseo cannot supply user data
or credentials. Restricted Codex modes keep their existing approval behavior.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
+1 -1
View File
@@ -1 +1 @@
sha256-RCp5Ogd8AETLmJCZaUebcgSPRk+is26nkUY7+blDb/g=
sha256-tB0WZG6F6VFMS5zFqo73giSAn7SqfkJISF5fMjDioJQ=
+80 -296
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.3.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.3.0",
"version": "0.3.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -9515,280 +9515,6 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@parcel/watcher": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
"integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.6.0",
"@parcel/watcher-darwin-arm64": "2.6.0",
"@parcel/watcher-darwin-x64": "2.6.0",
"@parcel/watcher-freebsd-x64": "2.6.0",
"@parcel/watcher-linux-arm-glibc": "2.6.0",
"@parcel/watcher-linux-arm-musl": "2.6.0",
"@parcel/watcher-linux-arm64-glibc": "2.6.0",
"@parcel/watcher-linux-arm64-musl": "2.6.0",
"@parcel/watcher-linux-x64-glibc": "2.6.0",
"@parcel/watcher-linux-x64-musl": "2.6.0",
"@parcel/watcher-win32-arm64": "2.6.0",
"@parcel/watcher-win32-x64": "2.6.0"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
"integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
"integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
"integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
"integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
"integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
"integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
"integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
"integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
"integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
"integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
"integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
"integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -23278,6 +23004,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -23331,6 +23058,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -25579,6 +25307,27 @@
"is-buffer": "~1.1.6"
}
},
"node_modules/mdast-util-directive": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz",
"integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"parse-entities": "^4.0.0",
"stringify-entities": "^4.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -26414,6 +26163,25 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-directive": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz",
"integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-factory-whitespace": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0",
"parse-entities": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
@@ -30588,6 +30356,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-directive": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz",
"integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-directive": "^3.0.0",
"micromark-extension-directive": "^4.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -35299,7 +35083,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36353,12 +36137,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.3.0",
"@getpaseo/protocol": "0.3.0",
"@getpaseo/server": "0.3.0",
"@getpaseo/client": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/server": "0.3.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36605,10 +36389,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"@getpaseo/protocol": "0.3.0",
"@getpaseo/relay": "0.3.0",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36619,7 +36403,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.3.0",
"version": "0.3.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36862,7 +36646,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.3.0",
"version": "0.3.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37758,7 +37542,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37990,7 +37774,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38003,7 +37787,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38221,19 +38005,18 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.3.0",
"@getpaseo/highlight": "0.3.0",
"@getpaseo/protocol": "0.3.0",
"@getpaseo/relay": "0.3.0",
"@getpaseo/client": "0.3.1",
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@parcel/watcher": "^2.6.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -38778,7 +38561,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -38793,6 +38576,7 @@
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.3",
"rehype-slug": "^6.0.0",
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"wrangler": "^4.75.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.3.0",
"version": "0.3.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -13,6 +13,7 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
openAddProjectHostSelection,
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
@@ -65,12 +66,14 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("a single connected host opens directly on method selection", async ({ page }) => {
test("method selection has no search field", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
await expect(addProjectFlowInput(page)).toHaveCount(0);
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
});
@@ -146,7 +149,7 @@ test.describe("Add Project command-center flow", () => {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await openAddProjectHostSelection(page);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
@@ -155,7 +158,7 @@ test.describe("Add Project command-center flow", () => {
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
});
test("Escape and Back restore page input and active selection before closing at the root", async ({
test("Escape and Back restore searchable page input before closing at the root", async ({
page,
}) => {
await gotoAppShell(page);
@@ -168,21 +171,20 @@ test.describe("Add Project command-center flow", () => {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await openAddProjectHostSelection(page);
await addProjectFlowInput(page).fill("o");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "method");
await addProjectFlowInput(page).fill("new");
await page.keyboard.press("Enter");
await chooseAddProjectMethod(page, "new-directory");
await expectAddProjectPage(page, "new-directory-parent");
await page.keyboard.press("Escape");
await expectAddProjectPage(page, "method");
await expect(addProjectFlowInput(page)).toHaveValue("new");
await page.keyboard.press("Enter");
await expect(addProjectFlowInput(page)).toHaveCount(0);
await chooseAddProjectMethod(page, "new-directory");
await expectAddProjectPage(page, "new-directory-parent");
await addProjectFlowBack(page).click();
@@ -216,7 +218,7 @@ test.describe("Add Project command-center flow", () => {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await openAddProjectHostSelection(page);
await addProjectFlowHost(page, SECONDARY_HOST_ID).click();
await expectAddProjectPage(page, "method");
@@ -118,6 +118,53 @@ async function selectAssistantText(page: Page, text: string): Promise<void> {
await selectAssistantTextRange(page, text, text);
}
async function selectAssistantListItemFromMarker(
page: Page,
listTag: "ol" | "ul",
itemText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const item = assistantMessage
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
.filter({ hasText: itemText })
.locator(':scope > [data-paseo-markdown-tag="li"]')
.filter({ hasText: itemText });
await item.evaluate((element, selectedEndText) => {
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
const markerText = marker?.firstChild;
if (!(markerText instanceof Text)) {
throw new Error("Expected rendered list marker text");
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let endNode: Text | null = null;
let endOffset = -1;
while (walker.nextNode()) {
const node = walker.currentNode;
const offset = node.textContent?.indexOf(selectedEndText) ?? -1;
if (node instanceof Text && offset >= 0) {
endNode = node;
endOffset = offset + selectedEndText.length;
break;
}
}
if (!endNode) {
throw new Error(`Could not find list item selection end: ${selectedEndText}`);
}
const range = document.createRange();
range.setStart(markerText, 0);
range.setEnd(endNode, endOffset);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}, endText);
}
async function doubleClickAssistantMarkdownText(
page: Page,
tag: "code" | "strong",
@@ -405,6 +452,24 @@ test("copying an assistant selection preserves Markdown structure and links", as
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
);
await selectAssistantListItemFromMarker(page, "ul", "First issue", "failure.");
await copySelection(page);
const draggedBulletClipboard = await readRichClipboard(page);
expect(draggedBulletClipboard.plainText).toBe(
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
);
expect(draggedBulletClipboard.html).toContain(
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
);
await selectAssistantListItemFromMarker(page, "ol", "Sixth item", "Sixth item");
await copySelection(page);
const draggedNumberClipboard = await readRichClipboard(page);
expect(draggedNumberClipboard.plainText).toBe("6. Sixth item");
expect(draggedNumberClipboard.html).toContain("<div>6. Sixth item</div>");
await selectAssistantText(page, "docs");
await copySelection(page);
@@ -0,0 +1,139 @@
import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import {
delayDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
} from "../support/helpers/command-center-file-search";
import { expectFileTabOpen } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const FILE_PATH =
"packages/app/src/command-center/features/workspace-file-search/layout-stability/needle-layout-stability-command-center.tsx";
const FILE_NAME = "needle-layout-stability-command-center.tsx";
const FILE_DIRECTORY =
"packages/app/src/command-center/features/workspace-file-search/layout-stability";
function paseoSizedFiles(): Array<{ path: string; content: string }> {
const files = Array.from({ length: 120 }, (_, index) => ({
path: `packages/app/src/features/feature-${String(index).padStart(3, "0")}/index.ts`,
content: `export const feature${index} = ${index};\n`,
}));
files.push({ path: FILE_PATH, content: "export const layoutNeedle = true;\n" });
return files;
}
async function seedMatchingWorkspaces(count: number): Promise<SeededWorkspace[]> {
return Promise.all(
Array.from({ length: count }, (_, index) =>
seedWorkspace({
repoPrefix: `command-center-file-search-error-${String(index).padStart(2, "0")}-`,
title: `Home failure ${String(index).padStart(2, "0")}`,
}),
),
);
}
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("workspace file search stays geometrically stable through delayed loading and results", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedWorkspace({
repoPrefix: "command-center-file-search-",
title: "Paseo-shaped file search",
repo: { files: paseoSizedFiles() },
});
try {
await delayDirectorySuggestionResponses(page, 800);
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
await startCommandCenterLayoutObservation(page);
await panel.getByTestId("command-center-input").fill(FILE_NAME);
const row = panel.getByRole("button", {
name: `${FILE_NAME} ${FILE_DIRECTORY}`,
});
await expect(row).toBeVisible({ timeout: 30_000 });
await expectOneLineTruncatedFileResult(row, {
filename: FILE_NAME,
path: FILE_DIRECTORY,
});
await panel.getByTestId("command-center-input").fill("needle-layout");
await page.waitForTimeout(150);
expect(await row.isVisible()).toBe(true);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expectStableCommandCenterLayout(page);
await row.click();
await expectFileTabOpen(page, FILE_PATH);
} finally {
await seeded.cleanup();
}
});
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-file-search-scope-",
title: "Scope chip height",
});
try {
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
const header = panel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
expect(scoped?.height).toBe(unscoped?.height);
} finally {
await seeded.cleanup();
}
});
test("unscoped command search keeps commands visible and reports file-search failures", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedMatchingWorkspaces(9);
try {
await failDirectorySuggestionRequests(page);
await gotoWorkspace(page, seeded[0].workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("home");
await expect(panel.getByRole("button", { name: "Home", exact: true })).toBeVisible();
await expect(panel.getByRole("button", { name: /Home failure 08/ })).toBeVisible();
await expect(panel.getByTestId("command-center-file-search-error")).toHaveText(
"Error: Test file search transport failure.",
);
await expect(panel.getByTestId("command-center-file-search-error")).toBeInViewport({
ratio: 1,
});
await expect(panel.getByText("No matches", { exact: true })).toHaveCount(0);
} finally {
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
}
});
@@ -160,7 +160,6 @@ test.describe("provider settings overlay stack", () => {
await commandCenter.getByText("Add project", { exact: true }).click();
const addProject = page.getByTestId("add-project-flow");
await expect(addProject).toBeVisible({ timeout: 10_000 });
await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused();
await page.keyboard.press("Escape");
await expect(addProject).not.toBeVisible({ timeout: 10_000 });
await expect(selector).toBeVisible();
@@ -114,16 +114,6 @@ test.describe("Settings sidebar navigation", () => {
await expectDirectHostUriHidden(page);
});
test("browser web hides direct connection custom headers", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await toggleHostAdvanced(page);
await expect(page.getByTestId("direct-header-add")).toHaveCount(0);
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -43,13 +43,21 @@ export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage)
return currentPage;
}
export async function openAddProjectFlow(
async function openAddProjectFlowSurface(
page: Page,
expectedPage: "host" | "method" = "method",
expectedPage: "host" | "method",
): Promise<void> {
await page.getByTestId("sidebar-add-project").click();
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
await expectAddProjectPage(page, expectedPage);
}
export async function openAddProjectFlow(page: Page): Promise<void> {
await openAddProjectFlowSurface(page, "method");
}
export async function openAddProjectHostSelection(page: Page): Promise<void> {
await openAddProjectFlowSurface(page, "host");
await expect(addProjectFlowInput(page)).toBeFocused();
}
@@ -0,0 +1,211 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
interface SurfaceRect {
height: number;
width: number;
x: number;
y: number;
}
interface LayoutObservation {
active: boolean;
layoutShift: number;
samples: Record<string, SurfaceRect[]>;
}
type LayoutOracleWindow = Window & {
__commandCenterLayoutObservation?: LayoutObservation;
};
const SURFACE_SELECTORS = {
panel: '[data-testid="command-center-panel"]',
header: '[data-testid="command-center-header"]',
input: '[data-testid="command-center-input"]',
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
results: '[data-testid="command-center-results"]',
scope: '[data-testid="command-center-files-scope"]',
fileRow: '[data-testid^="command-center-file-row-"]',
fileIcon: '[data-testid="command-center-file-icon"]',
fileLine: '[data-testid="command-center-file-line"]',
} as const;
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
export async function delayDirectorySuggestionResponses(page: Page, delayMs = 400): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type === "directory_suggestions_response") {
setTimeout(() => ws.send(message), delayMs);
return;
}
ws.send(message);
});
});
}
export async function failDirectorySuggestionRequests(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type === "directory_suggestions_request") {
const requestId = sessionMessage.requestId;
if (typeof requestId === "string") {
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "directory_suggestions_request",
error: "Test file search transport failure.",
code: "transport",
},
},
}),
);
}
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
}
export async function startCommandCenterLayoutObservation(page: Page): Promise<void> {
await page.evaluate((selectors) => {
const oracleWindow = window as LayoutOracleWindow;
const observation: LayoutObservation = { active: true, layoutShift: 0, samples: {} };
oracleWindow.__commandCenterLayoutObservation = observation;
const observedElements = new WeakSet<Element>();
const resizeObserver = new ResizeObserver(record);
const mutationObserver = new MutationObserver(discover);
function record(): void {
if (!observation.active) return;
for (const [name, selector] of Object.entries(selectors)) {
const element = document.querySelector<HTMLElement>(selector);
if (!element) continue;
const rect = element.getBoundingClientRect();
(observation.samples[name] ??= []).push({
height: rect.height,
width: rect.width,
x: rect.x,
y: rect.y,
});
}
}
function discover(): void {
for (const selector of Object.values(selectors)) {
const element = document.querySelector(selector);
if (!element || observedElements.has(element)) continue;
observedElements.add(element);
resizeObserver.observe(element);
}
record();
}
mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true });
discover();
const sampleFrame = () => {
if (!observation.active) {
mutationObserver.disconnect();
resizeObserver.disconnect();
return;
}
record();
requestAnimationFrame(sampleFrame);
};
requestAnimationFrame(sampleFrame);
if (typeof PerformanceObserver !== "undefined") {
try {
const performanceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
}
});
performanceObserver.observe({ type: "layout-shift", buffered: false });
} catch {
// LayoutShift is diagnostic-only on engines that do not expose it.
}
}
}, SURFACE_SELECTORS);
}
export async function expectStableCommandCenterLayout(page: Page): Promise<void> {
await page.waitForTimeout(500);
const observation = await page.evaluate(() => {
const state = (window as LayoutOracleWindow).__commandCenterLayoutObservation;
if (!state) throw new Error("Command center layout observer was not installed");
state.active = false;
return state;
});
for (const surface of Object.keys(SURFACE_SELECTORS)) {
const samples = observation.samples[surface] ?? [];
expect(samples.length, `${surface}: ${JSON.stringify(observation)}`).toBeGreaterThan(0);
for (const dimension of ["height", "width", "x", "y"] as const) {
const values = samples.map((sample) => sample[dimension]);
expect(
Math.max(...values) - Math.min(...values),
`${surface}.${dimension}: ${JSON.stringify(samples)}`,
).toBeLessThanOrEqual(1);
}
}
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
}
export async function expectOneLineTruncatedFileResult(
row: Locator,
input: { filename: string; path: string },
): Promise<void> {
await expect(row).toContainText(`${input.filename} ${input.path}`);
await expect(row.getByTestId("command-center-file-icon")).toBeVisible();
const line = row.getByTestId("command-center-file-line");
const filename = row.getByTestId("command-center-file-name");
const filePath = row.getByTestId("command-center-file-path");
const geometry = await Promise.all([
row.boundingBox(),
line.boundingBox(),
filename.boundingBox(),
filePath.boundingBox(),
line.evaluate((element) => ({
clientWidth: element.clientWidth,
overflow: getComputedStyle(element).overflow,
scrollWidth: element.scrollWidth,
whiteSpace: getComputedStyle(element).whiteSpace,
})),
]);
const [rowBox, lineBox, filenameBox, pathBox, overflow] = geometry;
expect(rowBox).not.toBeNull();
expect(lineBox).not.toBeNull();
expect(filenameBox).not.toBeNull();
expect(pathBox).not.toBeNull();
expect(rowBox?.height).toBe(36);
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
expect(overflow.overflow).toBe("hidden");
expect(overflow.whiteSpace).toBe("nowrap");
}
@@ -17,6 +17,49 @@ interface PairingHostInput {
endpoint: string;
}
export async function prepareLocalPairingHost(
page: Page,
daemon: IsolatedHostDaemon | OutdatedDaemon,
additionalHosts: PairingHostInput[] = [],
): Promise<void> {
await page.addInitScript((localServerId) => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
if (command === "desktop_daemon_status") {
return {
serverId: localServerId,
status: "running",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
};
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
opener: {
openUrl: async (url: string) => {
localStorage.setItem("@paseo:e2e-opened-url", url);
},
},
};
}, daemon.serverId);
await preparePairingHost(page, daemon, additionalHosts);
}
export async function preparePairingHost(
page: Page,
daemon: IsolatedHostDaemon | OutdatedDaemon,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.3.0",
"version": "0.3.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -39,9 +39,9 @@ describe("Add Project navigation", () => {
expect(currentAddProjectPage(state)).toEqual({
kind: "method",
hostId: "host-1",
query: "",
activeIndex: 0,
error: null,
isSubmitting: false,
});
expect(backAddProjectPage(state)).toBeNull();
});
+11 -5
View File
@@ -17,15 +17,18 @@ export interface GithubRepositoryChoice {
updatedAt: string | null;
}
interface SearchPageState {
query: string;
interface PageState {
activeIndex: number;
error: string | null;
}
interface SearchPageState extends PageState {
query: string;
}
export type AddProjectPage =
| ({ kind: "host" } & SearchPageState)
| ({ kind: "method"; hostId: string } & SearchPageState)
| ({ kind: "method"; hostId: string; isSubmitting: boolean } & PageState)
| ({ kind: "directory-search"; hostId: string; isSubmitting: boolean } & SearchPageState)
| ({ kind: "github-search"; hostId: string } & SearchPageState)
| ({
@@ -57,12 +60,14 @@ export interface OpenAddProjectFlowInput {
preferredHostId?: string;
}
function searchPage<TKind extends AddProjectPage["kind"]>(kind: TKind) {
type SearchPageKind = Extract<AddProjectPage, { query: string }>["kind"];
function searchPage<TKind extends SearchPageKind>(kind: TKind) {
return { kind, query: "", activeIndex: 0, error: null } as const;
}
function methodPage(hostId: string): AddProjectPage {
return { ...searchPage("method"), hostId };
return { kind: "method", hostId, activeIndex: 0, error: null, isSubmitting: false };
}
export function openAddProjectFlow(input: OpenAddProjectFlowInput): AddProjectFlowState {
@@ -209,6 +214,7 @@ export function setAddProjectPageInput(
if (current.kind === "new-directory-name") {
return { ...current, name: value, activeIndex: 0, error: null };
}
if (current.kind === "method") return current;
return { ...current, query: value, activeIndex: 0, error: null };
});
if (page.kind !== "github-location") return updated;
@@ -5,8 +5,8 @@ const fixture = `
<div data-testid="assistant-message">
<div data-paseo-markdown-tag="p">Prefix <span data-paseo-markdown-tag="strong">bold text</span> and <span data-paseo-markdown-tag="code">inline code</span> suffix.</div>
<div data-paseo-markdown-tag="ul">
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>First bullet text</span></div></div>
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>Second bullet text</span></div></div>
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span><div><span>First bullet text</span></div></div>
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span><div><span>Second bullet text</span></div></div>
</div>
<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="ts"><span data-paseo-markdown-tag="code">const answer = true;</span></div>
</div>
@@ -203,6 +203,97 @@ describe("assistant selection copy ranges", () => {
expect(copiedMarkdown(selectNodeContents(item))).toBe("- First bullet text");
});
it("retains a bullet when a drag selects from the rendered marker through the item text", () => {
const message = mountFixture();
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
const content = createAssistantSelectionClipboardContent(
selectRange(marker, 0, itemText, textNode(itemText).length),
);
expect(content?.plainText).toBe("- First bullet text");
expect(content?.html).toContain("<div>- First bullet text</div>");
expect(content?.html).not.toContain("<ul>");
expect(content?.html).not.toContain("<li>");
});
it("retains a bullet when a drag includes the marker and part of the item text", () => {
const message = mountFixture();
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
expect(copiedMarkdown(selectRange(marker, 0, itemText, 5))).toBe("- First");
});
it("does not invent a bullet when a drag starts after the rendered marker", () => {
const message = mountFixture();
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
expect(
copiedMarkdown(
selectRange(marker, textNode(marker).length, itemText, textNode(itemText).length),
),
).toBe("First bullet text");
});
it("retains every selected marker across a partial multi-item drag", () => {
const message = mountFixture();
const markerSelector = '[data-paseo-markdown-list-marker="true"]';
const textSelector = '[data-paseo-markdown-tag="li"] div span';
const firstMarker = fixtureElement(message, markerSelector);
const secondText = fixtureElement(message, textSelector, 1);
expect(copiedMarkdown(selectRange(firstMarker, 0, secondText, 6))).toBe(
"- First bullet text\n- Second",
);
});
it("retains the original number when a marker drag starts mid-list", () => {
const message = mountFixture();
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
list.setAttribute("data-paseo-markdown-tag", "ol");
list.setAttribute("data-paseo-markdown-list-start", "5");
const markers = list.querySelectorAll<HTMLElement>('[data-paseo-markdown-list-marker="true"]');
markers.item(0).textContent = "5.";
markers.item(1).textContent = "6.";
const secondMarker = fixtureElement(list, '[data-paseo-markdown-list-marker="true"]', 1);
const secondText = fixtureElement(list, '[data-paseo-markdown-tag="li"] div span', 1);
expect(
copiedMarkdown(selectRange(secondMarker, 0, secondText, textNode(secondText).length)),
).toBe("6. Second bullet text");
});
it("retains nested markers when a drag includes the complete outer item", () => {
const message = mountFixture();
const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
firstItem.innerHTML = [
'<span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span>',
"<div>",
"<span>Outer text</span>",
'<div data-paseo-markdown-tag="ul">',
'<div data-paseo-markdown-tag="li">',
'<span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span>',
"<div><span>Inner text</span></div>",
"</div>",
"</div>",
"</div>",
].join("");
const outerMarker = fixtureElement(
firstItem,
':scope > [data-paseo-markdown-list-marker="true"]',
);
const innerText = fixtureElement(
firstItem,
':scope [data-paseo-markdown-tag="ul"] > [data-paseo-markdown-tag="li"] > div > span',
);
expect(copiedMarkdown(selectRange(outerMarker, 0, innerText, textNode(innerText).length))).toBe(
"- Outer text\n - Inner text",
);
});
it("preserves paragraph breaks when rich HTML flattens a loose list item", () => {
const message = mountFixture();
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
@@ -9,6 +9,7 @@ import {
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
MARKDOWN_COPY_TAG_ATTRIBUTE,
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
@@ -278,7 +279,7 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
if (tag === "p" || isTableStructure(tag)) {
return true;
}
const isSelectableSemantic = tag !== null && tag !== "ol" && tag !== "ul";
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
if (
(isSelectableSemantic || element.tagName === "A") &&
selectionStaysInsideElement(range, element)
@@ -287,7 +288,12 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
}
if (tag === "ol" || tag === "ul") {
const selectedItems = selectedListItems(element, range);
return selectedItems.some((item) => hasSelectedAllContents(range, item, true));
return selectedItems.some(
(item) => hasSelectedListMarker(range, item) || hasSelectedAllContents(range, item, true),
);
}
if (tag === "li" && hasSelectedListMarker(range, element)) {
return true;
}
return hasSelectedAllContents(range, element, tag === "li");
}
@@ -311,6 +317,31 @@ function selectedListItems(list: Element, range: Range): Element[] {
);
}
function hasSelectedListMarker(range: Range, item: Element): boolean {
const marker = item.querySelector(`:scope > [${MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE}]`);
if (!marker) {
return false;
}
const markerContents = document.createRange();
markerContents.selectNodeContents(marker);
const selectionEndsBeforeMarker =
range.compareBoundaryPoints(Range.END_TO_START, markerContents) >= 0;
const selectionStartsAfterMarker =
range.compareBoundaryPoints(Range.START_TO_END, markerContents) <= 0;
if (selectionEndsBeforeMarker || selectionStartsAfterMarker) {
return false;
}
if (range.compareBoundaryPoints(Range.START_TO_START, markerContents) > 0) {
markerContents.setStart(range.startContainer, range.startOffset);
}
if (range.compareBoundaryPoints(Range.END_TO_END, markerContents) < 0) {
markerContents.setEnd(range.endContainer, range.endOffset);
}
return hasMarkdownContent(markerContents.cloneContents(), true);
}
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
return;
@@ -1,5 +1,6 @@
export const MARKDOWN_COPY_TAG_ATTRIBUTE = "data-paseo-markdown-tag";
export const MARKDOWN_COPY_IGNORE_ATTRIBUTE = "data-paseo-markdown-ignore";
export const MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE = "data-paseo-markdown-list-marker";
export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
@@ -28,6 +29,7 @@ export const markdownCopyDataSet = {
hr: { paseoMarkdownTag: "hr" },
ignore: { paseoMarkdownIgnore: "true" },
li: { paseoMarkdownTag: "li" },
listMarker: { paseoMarkdownIgnore: "true", paseoMarkdownListMarker: "true" },
ol: { paseoMarkdownTag: "ol" },
p: { paseoMarkdownTag: "p" },
pre: { paseoMarkdownTag: "pre" },
@@ -13,7 +13,7 @@ import {
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder } from "lucide-react-native";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
BottomSheetBackdrop,
@@ -22,6 +22,8 @@ import {
type BottomSheetFlatListMethods,
} from "@gorhom/bottom-sheet";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Shortcut } from "@/components/ui/shortcut";
import {
IsolatedBottomSheetModal,
@@ -37,7 +39,10 @@ import {
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import {
useKeyboardShortcutsStore,
type CommandCenterScope,
} from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import {
@@ -56,11 +61,13 @@ import {
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterAgentResult,
type CommandCenterFileResult,
type CommandCenterListRow,
type CommandCenterResult,
type CommandCenterResultSection,
type CommandCenterWorkspaceResult,
} from "./results";
import { useWorkspaceFileSearch } from "./workspace-file-search";
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
@@ -73,6 +80,10 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
@@ -191,6 +202,8 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
interface CommandCenterState {
open: boolean;
scope: CommandCenterScope;
clearScope(): void;
query: string;
setQuery(query: string): void;
activeId: string | null;
@@ -199,27 +212,58 @@ interface CommandCenterState {
rowIndexByResultId: ReadonlyMap<string, number>;
offsets: readonly number[];
inputRef: React.RefObject<TextInput | null>;
fileSearchLoading: boolean;
fileSearchError: string | null;
close(): void;
select(result: CommandCenterResult): void;
key(key: string): boolean;
}
function useCommandCenterState(): CommandCenterState {
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope);
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope);
const snapshot = useCommandCenterContributions();
const inputRef = useRef<TextInput>(null);
const previousOpenRef = useRef(open);
const [query, setQuery] = useState("");
const [activeId, setActiveId] = useState<string | null>(null);
const builtInSections = useBuiltInSections(open, query);
const {
entries: fileSearchEntries,
loading: fileSearchLoading,
error: fileSearchError,
openFile,
} = useWorkspaceFileSearch({
enabled: open && (scope === "files" || Boolean(query.trim())),
query,
});
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
const results = fileSearchEntries.map<CommandCenterFileResult>((entry) => ({
kind: "file",
id: `file:${entry.path}`,
filePath: entry.path,
title: entry.name,
subtitle: entry.directory,
searchText: entry.path.toLowerCase(),
run: () => openFile(entry.path),
}));
return [{ id: "files", rank: 4, title: t("shell.commandCenter.files"), results }];
}, [fileSearchEntries, openFile, t]);
const contributionSections = useMemo(
() => buildContributionSections(snapshot.contributions, query),
[query, snapshot.contributions],
);
const projection = useMemo(
() => projectCommandCenterRows([...contributionSections, ...builtInSections]),
[builtInSections, contributionSections],
() =>
projectCommandCenterRows(
scope === "files"
? fileSections
: [...contributionSections, ...fileSections, ...builtInSections],
),
[builtInSections, contributionSections, fileSections, scope],
);
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
@@ -239,6 +283,10 @@ function useCommandCenterState(): CommandCenterState {
close();
return true;
}
if (pressed === "Backspace" && !query.trim() && scope) {
setScope(null);
return true;
}
if (pressed === "Enter") {
const selected = results.find((result) => result.id === resolvedActiveId);
if (!selected) return false;
@@ -251,7 +299,7 @@ function useCommandCenterState(): CommandCenterState {
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
return true;
},
[close, open, projection.selectableResults, resolvedActiveId, select],
[close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope],
);
useEffect(() => {
@@ -277,6 +325,8 @@ function useCommandCenterState(): CommandCenterState {
return {
open,
scope,
clearScope: () => setScope(null),
query,
setQuery,
activeId: resolvedActiveId,
@@ -285,6 +335,8 @@ function useCommandCenterState(): CommandCenterState {
rowIndexByResultId: projection.rowIndexByResultId,
offsets: projection.offsets,
inputRef,
fileSearchLoading,
fileSearchError,
close,
select,
key,
@@ -303,7 +355,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
? result.contribution.presentation
: null;
const accessibilityLabel = choice?.path.join(" › ");
const accessibilityLabel =
result.kind === "file"
? [result.title, result.subtitle].filter(Boolean).join(" ")
: choice?.path.join(" › ");
const accessibilityState = useMemo(
() => (isNative && choice ? { selected: choice.selected } : undefined),
[choice],
@@ -329,7 +384,9 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
accessibilityLabel={accessibilityLabel}
accessibilityState={accessibilityState}
aria-pressed={isWeb ? choice?.selected : undefined}
testID={choice?.testId}
testID={
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
}
>
<ResultContent result={result} />
</Pressable>
@@ -337,6 +394,28 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
});
function ResultContent({ result }: { result: CommandCenterResult }) {
if (result.kind === "file") {
return (
<View style={styles.rowContent}>
<View style={styles.rowMain}>
<View style={styles.iconSlot} testID="command-center-file-icon">
<MaterialFileIcon fileName={result.title} size={16} />
</View>
<Text style={styles.fileLine} numberOfLines={1} testID="command-center-file-line">
<Text style={styles.fileName} testID="command-center-file-name">
{result.title}
</Text>
{result.subtitle ? (
<Text style={styles.filePath} testID="command-center-file-path">
{" "}
{result.subtitle}
</Text>
) : null}
</Text>
</View>
</View>
);
}
if (result.kind === "agent") {
const agent = result.agent;
return (
@@ -545,8 +624,24 @@ export function CommandCenter() {
);
const keyExtractor = useCallback((row: CommandCenterListRow) => row.key, []);
const empty = useMemo(
() => <Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>,
[t],
() =>
state.fileSearchError || state.fileSearchLoading ? null : (
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
),
[state.fileSearchError, state.fileSearchLoading, t],
);
const fileSearchError = useMemo(
() =>
state.fileSearchError ? (
<Text
accessibilityLiveRegion="polite"
style={styles.errorText}
testID="command-center-file-search-error"
>
{t("common.errors.error")}: {state.fileSearchError}
</Text>
) : null,
[state.fileSearchError, t],
);
const commonListProps = {
data: state.rows,
@@ -554,6 +649,8 @@ export function CommandCenter() {
keyExtractor,
getItemLayout,
ListEmptyComponent: empty,
style: styles.results,
testID: "command-center-results",
keyboardShouldPersistTaps: KEYBOARD_SHOULD_PERSIST_TAPS,
showsVerticalScrollIndicator: false,
initialNumToRender: 12,
@@ -606,7 +703,10 @@ export function CommandCenter() {
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<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}
@@ -614,13 +714,22 @@ export function CommandCenter() {
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
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} />
</IsolatedBottomSheetModal>
);
@@ -632,20 +741,32 @@ export function CommandCenter() {
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={styles.header}>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
value={state.query}
onChangeText={state.setQuery}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
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>
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</View>
</View>
</Modal>
@@ -653,6 +774,41 @@ export function CommandCenter() {
);
}
function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) {
return (
<View style={styles.fileSearchStatus} testID="command-center-file-search-status">
{loading ? (
<View
accessibilityLabel={label}
accessibilityRole="progressbar"
testID="command-center-file-search-loading"
>
<ThemedLoadingSpinner size={14} />
</View>
) : null}
</View>
);
}
// The chip sits at the input's own height and type size so it reads as the scope of the field
// rather than a badge dropped into the query. Its 28px matches the input's line box, so showing or
// dropping the scope cannot resize the header.
function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onRemove}
style={styles.scopeChip}
testID="command-center-files-scope"
>
<ThemedFolder size={14} strokeWidth={2.2} />
<Text style={styles.scopeChipLabel}>{label}</Text>
<ThemedX size={12} strokeWidth={2.2} />
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
@@ -663,6 +819,7 @@ const styles = StyleSheet.create((theme) => ({
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
panel: {
width: 640,
height: 560,
maxWidth: "92%",
maxHeight: "80%",
borderWidth: 1,
@@ -690,7 +847,29 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
outlineWidth: 0,
},
results: { flexGrow: 0 },
growingInput: { flex: 1, minWidth: 0 },
searchRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
fileSearchStatus: { width: 14, height: 14, alignItems: "center", justifyContent: "center" },
scopeChip: {
// Stretching to the row rather than setting a height keeps the chip exactly as tall as the
// input's line box, so showing or dropping the scope can never resize the header.
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
marginRight: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface2,
},
scopeChipLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "500",
},
results: { flex: 1 },
sectionLabel: {
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
@@ -726,6 +905,9 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 18,
flexShrink: 1,
},
fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.sm, lineHeight: 20 },
fileName: { color: theme.colors.foreground },
filePath: { color: theme.colors.foregroundMuted },
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16 },
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
rowShortcut: { flexShrink: 0 },
@@ -755,6 +937,14 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorText: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
},
sheetBackground: { backgroundColor: theme.colors.surface0 },
sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
titledSection: { height: 32, justifyContent: "flex-end" },
@@ -20,6 +20,16 @@ export interface CommandCenterAgentResult {
run(): void;
}
export interface CommandCenterFileResult {
kind: "file";
id: string;
filePath: string;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
export interface CommandCenterContributionResult {
kind: "contribution";
id: string;
@@ -31,6 +41,7 @@ export interface CommandCenterContributionResult {
export type CommandCenterResult =
| CommandCenterWorkspaceResult
| CommandCenterAgentResult
| CommandCenterFileResult
| CommandCenterContributionResult;
export interface CommandCenterResultSection {
@@ -78,6 +89,7 @@ function contributionSearchText(contribution: CommandCenterContribution): string
function resultHeight(result: CommandCenterResult): number {
if (result.kind === "workspace" || result.kind === "agent") return 56;
if (result.kind === "file") return 36;
if (result.contribution.presentation.kind === "action") {
return result.contribution.presentation.subtitle ? 56 : 36;
}
@@ -0,0 +1,15 @@
export interface WorkspaceFileSearchEntry {
path: string;
name: string;
directory: string;
}
export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry {
const normalized = path.replace(/\\/g, "/");
const separator = normalized.lastIndexOf("/");
return {
path: normalized,
name: separator >= 0 ? normalized.slice(separator + 1) : normalized,
directory: separator >= 0 ? normalized.slice(0, separator) : "",
};
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { describeWorkspaceFilePath } from "./workspace-file-search-model";
describe("describeWorkspaceFilePath", () => {
it("separates a workspace-relative file into its row labels", () => {
expect(describeWorkspaceFilePath("src/components/message.tsx")).toEqual({
path: "src/components/message.tsx",
name: "message.tsx",
directory: "src/components",
});
});
it("normalizes Windows separators before opening or presenting a file", () => {
expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({
path: "src/components/message.tsx",
name: "message.tsx",
directory: "src/components",
});
});
it("keeps root files free of a redundant directory label", () => {
expect(describeWorkspaceFilePath("package.json")).toEqual({
path: "package.json",
name: "package.json",
directory: "",
});
});
});
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
describeWorkspaceFilePath,
type WorkspaceFileSearchEntry,
} from "./workspace-file-search-model";
interface DirectorySuggestionEntry {
path: string;
}
const FILE_SEARCH_DEBOUNCE_MS = 100;
const FILE_SEARCH_LIMIT = 100;
interface WorkspaceFileSearchState {
sourceKey: string | null;
requestKey: string | null;
entries: readonly WorkspaceFileSearchEntry[];
loading: boolean;
error: string | null;
}
const EMPTY_STATE: WorkspaceFileSearchState = {
sourceKey: null,
requestKey: null,
entries: [],
loading: false,
error: null,
};
function errorMessage(error: unknown): string {
if (!(error instanceof Error)) return String(error);
if (error.name !== "DaemonRpcError") return error.message;
return error.message.replace(/ requestType=\S+(?: code=\S+)?$/, "");
}
function describeFileEntries(
entries: readonly DirectorySuggestionEntry[],
): WorkspaceFileSearchEntry[] {
return entries.map(({ path }) => describeWorkspaceFilePath(path));
}
export function useWorkspaceFileSearch(input: { enabled: boolean; query: string }): {
entries: readonly WorkspaceFileSearchEntry[];
loading: boolean;
error: string | null;
openFile(path: string): void;
} {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const client = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.client ?? null) : null,
);
const [state, setState] = useState<WorkspaceFileSearchState>(EMPTY_STATE);
const sourceKey = useMemo(
() => (serverId && workspaceId && cwd && client ? `${serverId}\0${workspaceId}\0${cwd}` : null),
[client, cwd, serverId, workspaceId],
);
const requestKey = useMemo(
() => (input.enabled && sourceKey ? `${sourceKey}\0${input.query}` : null),
[input.enabled, input.query, sourceKey],
);
useEffect(() => {
if (!requestKey || !client || !cwd) {
setState(EMPTY_STATE);
return;
}
const activeClient = client;
const activeCwd = cwd;
let cancelled = false;
setState((previous) => ({
sourceKey,
requestKey,
entries: previous.sourceKey === sourceKey ? previous.entries : [],
loading: true,
error: null,
}));
async function search(): Promise<void> {
try {
const payload = await activeClient.getDirectorySuggestions({
cwd: activeCwd,
query: input.query,
includeFiles: true,
includeDirectories: false,
limit: FILE_SEARCH_LIMIT,
});
if (cancelled) return;
setState({
sourceKey,
requestKey,
entries: payload.error ? [] : describeFileEntries(payload.entries),
loading: false,
error: payload.error ?? null,
});
} catch (error) {
if (!cancelled) {
setState({
sourceKey,
requestKey,
entries: [],
loading: false,
error: errorMessage(error),
});
}
}
}
const timer = setTimeout(() => void search(), input.query.trim() ? FILE_SEARCH_DEBOUNCE_MS : 0);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [client, cwd, input.query, requestKey, sourceKey]);
const openFile = useCallback(
(path: string) => {
if (!serverId || !workspaceId) return;
clearCommandCenterFocusRestoreElement();
openWorkspaceFileFromExplorer({
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabFocused: useWorkspaceLayoutStore.getState().openTabFocused,
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
});
},
[serverId, workspaceId],
);
return {
entries: requestKey && state.sourceKey === sourceKey ? state.entries : [],
loading: Boolean(requestKey) && (state.requestKey !== requestKey || state.loading),
error: state.requestKey === requestKey ? state.error : null,
openFile,
};
}
+20 -201
View File
@@ -1,10 +1,9 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useCallback, useMemo, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getIsElectron, isNative } from "@/constants/platform";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2, Plus, X } from "lucide-react-native";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import {
@@ -13,11 +12,6 @@ import {
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import {
prepareConnectionHeaders,
type ConnectionHeaderDraft,
type ConnectionHeaderValidationIssue,
} from "@/utils/connection-headers";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -129,30 +123,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
advancedContent: {
gap: theme.spacing[4],
},
headerSection: {
gap: theme.spacing[2],
},
headerTitleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
headerList: {
gap: theme.spacing[2],
},
headerRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerInput: {
flex: 1,
minWidth: 0,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
@@ -211,94 +181,6 @@ function prepareDirectConnection(
};
}
function headerIssueMessage(
issue: ConnectionHeaderValidationIssue,
t: ReturnType<typeof useTranslation>["t"],
): string {
if (issue.type === "missingName") return t("pairing.direct.headers.errors.missingName");
if (issue.type === "invalidName") {
return t("pairing.direct.headers.errors.invalidName", { name: issue.name });
}
if (issue.type === "invalidValue") {
return t("pairing.direct.headers.errors.invalidValue", { name: issue.name });
}
return t("pairing.direct.headers.errors.duplicateName", { name: issue.name });
}
interface ConnectionHeaderRowProps {
draft: ConnectionHeaderDraft;
index: number;
disabled: boolean;
placeholderTextColor: string;
iconColor: string;
onUpdate: (id: number, field: "name" | "value", value: string) => void;
onRemove: (id: number) => void;
}
function ConnectionHeaderRow({
draft,
index,
disabled,
placeholderTextColor,
iconColor,
onUpdate,
onRemove,
}: ConnectionHeaderRowProps) {
const { t } = useTranslation();
const handleNameChange = useCallback(
(value: string) => onUpdate(draft.id, "name", value),
[draft.id, onUpdate],
);
const handleValueChange = useCallback(
(value: string) => onUpdate(draft.id, "value", value),
[draft.id, onUpdate],
);
const handleRemove = useCallback(() => onRemove(draft.id), [draft.id, onRemove]);
return (
<View style={styles.headerRow}>
<AdaptiveTextInput
testID={`direct-header-name-${index}`}
nativeID={`direct-header-name-${draft.id}`}
accessibilityLabel={t("pairing.direct.headers.name")}
initialValue={draft.name}
value={draft.name}
onChangeText={handleNameChange}
placeholder={t("pairing.direct.headers.name")}
placeholderTextColor={placeholderTextColor}
style={[styles.input, styles.headerInput]}
autoCapitalize="none"
autoCorrect={false}
editable={!disabled}
/>
<AdaptiveTextInput
testID={`direct-header-value-${index}`}
nativeID={`direct-header-value-${draft.id}`}
accessibilityLabel={t("pairing.direct.headers.value")}
initialValue={draft.value}
value={draft.value}
onChangeText={handleValueChange}
placeholder={t("pairing.direct.headers.value")}
placeholderTextColor={placeholderTextColor}
style={[styles.input, styles.headerInput]}
autoCapitalize="none"
autoCorrect={false}
editable={!disabled}
/>
<Pressable
style={styles.iconButton}
onPress={handleRemove}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={t("pairing.direct.headers.remove")}
testID={`direct-header-remove-${index}`}
>
<X size={18} color={iconColor} />
</Pressable>
</View>
);
}
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
const parsed = parseConnectionUri(uri);
return {
@@ -423,8 +305,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const [headerDrafts, setHeaderDrafts] = useState<ConnectionHeaderDraft[]>([]);
const nextHeaderId = useRef(1);
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => {
@@ -435,8 +315,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
setHeaderDrafts([]);
nextHeaderId.current = 1;
bumpInputResetKey();
}, []);
@@ -491,18 +369,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
if (isSaving) return;
let connection: PreparedDirectConnection;
let headers: Record<string, string> | undefined;
try {
connection = prepareDirectConnection(
{ host, port, useTls, password },
directConnectionLabels,
);
const preparedHeaders = prepareConnectionHeaders(headerDrafts);
if (preparedHeaders.issue) {
setErrorMessage(headerIssueMessage(preparedHeaders.issue, t));
return;
}
headers = preparedHeaders.headers;
} catch (error) {
const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
@@ -518,7 +389,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
...(headers ? { headers } : {}),
});
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
@@ -555,7 +425,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
daemons,
directConnectionLabels,
handleClose,
headerDrafts,
host,
isMobile,
isSaving,
@@ -584,22 +453,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible((current) => !current);
}, []);
const handleAddHeader = useCallback(() => {
const id = nextHeaderId.current;
nextHeaderId.current += 1;
setHeaderDrafts((current) => [...current, { id, name: "", value: "" }]);
}, []);
const handleUpdateHeader = useCallback((id: number, field: "name" | "value", value: string) => {
setHeaderDrafts((current) =>
current.map((draft) => (draft.id === id ? { ...draft, [field]: value } : draft)),
);
}, []);
const handleRemoveHeader = useCallback((id: number) => {
setHeaderDrafts((current) => current.filter((draft) => draft.id !== id));
}, []);
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
try {
@@ -756,58 +609,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable>
{isAdvancedOpen ? (
<View style={styles.advancedContent}>
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleToggleAdvanced}
/>
{isNative || getIsElectron() ? (
<View style={styles.headerSection}>
<View style={styles.headerTitleRow}>
<Text style={styles.label}>{t("pairing.direct.headers.title")}</Text>
<Button
variant="ghost"
size="sm"
leftIcon={Plus}
onPress={handleAddHeader}
disabled={isSaving}
testID="direct-header-add"
>
{t("pairing.direct.headers.add")}
</Button>
</View>
<View style={styles.headerList}>
{headerDrafts.map((draft, index) => (
<ConnectionHeaderRow
key={draft.id}
draft={draft}
index={index}
disabled={isSaving}
placeholderTextColor={theme.colors.foregroundMuted}
iconColor={theme.colors.foregroundMuted}
onUpdate={handleUpdateHeader}
onRemove={handleRemoveHeader}
/>
))}
</View>
</View>
) : null}
</View>
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleToggleAdvanced}
/>
) : null}
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
@@ -69,7 +69,7 @@ import {
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getIsElectronRuntime } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { pickDirectory } from "@/desktop/pick-directory";
import { useFetchQuery } from "@/data/query";
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
@@ -222,12 +222,12 @@ function pageTitle(page: AddProjectPage): string {
}
}
function pagePlaceholder(page: AddProjectPage): string {
type AddProjectInputPage = Exclude<AddProjectPage, { kind: "method" }>;
function pagePlaceholder(page: AddProjectInputPage): string {
switch (page.kind) {
case "host":
return "Search hosts...";
case "method":
return "Search methods...";
case "directory-search":
return "Search directories or enter a path...";
case "github-search":
@@ -240,7 +240,7 @@ function pagePlaceholder(page: AddProjectPage): string {
}
}
function pageInput(page: AddProjectPage): string {
function pageInput(page: AddProjectInputPage): string {
return page.kind === "new-directory-name" ? page.name : page.query;
}
@@ -374,7 +374,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const inputRef = useRef<TextInput>(null);
const submissionInFlightRef = useRef(false);
const browseInFlightRef = useRef(false);
const query = page.kind === "new-directory-name" ? "" : page.query;
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query;
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
@@ -599,23 +599,15 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
}
if (page.kind === "method") {
if (!host) return [];
const normalized = page.query.trim().toLowerCase();
return buildAddProjectMethods(host)
.filter(
(method) =>
!normalized ||
method.label.toLowerCase().includes(normalized) ||
method.description.toLowerCase().includes(normalized),
)
.map((method) => ({
id: method.id,
title: method.label,
subtitle: method.description,
icon: methodIcon(method.id),
disabled: method.disabled,
testID: `add-project-flow-method-${method.id}`,
select: () => selectMethod(method.id),
}));
return buildAddProjectMethods(host).map((method) => ({
id: method.id,
title: method.label,
subtitle: method.description,
icon: methodIcon(method.id),
disabled: method.disabled,
testID: `add-project-flow-method-${method.id}`,
select: () => selectMethod(method.id),
}));
}
if (page.kind === "directory-search") {
return pathOptions.map((option) => {
@@ -863,21 +855,41 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
) : null}
</View>
</View>
<ThemedTextInput
key={page.kind}
ref={inputRef}
value={pageInput(page)}
onChangeText={handleInputChange}
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
onSubmitEditing={isWeb ? undefined : submitActive}
placeholder={pagePlaceholder(page)}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
editable={!isSubmitting}
returnKeyType="go"
testID="add-project-flow-input"
/>
{page.kind === "method" && isNative ? (
// Native hardware-keyboard events need a focused responder even without a visible field.
<TextInput
key={page.kind}
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
key={page.kind}
ref={inputRef}
value={pageInput(page)}
onChangeText={handleInputChange}
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
onSubmitEditing={isWeb ? undefined : submitActive}
placeholder={pagePlaceholder(page)}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
editable={!isSubmitting}
returnKeyType="go"
testID="add-project-flow-input"
/>
) : null}
</View>
<ScrollView
style={styles.results}
@@ -1012,6 +1024,12 @@ 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: {
@@ -7,6 +7,7 @@ import {
} from "@getpaseo/protocol/agent-lifecycle";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
export function AgentStatusDot({
status,
@@ -56,8 +57,8 @@ function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus {
const styles = StyleSheet.create((theme) => ({
dot: {
width: 8,
height: 8,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
},
}));
@@ -6,37 +6,16 @@ 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";
import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import {
buildEffectiveBindings,
buildKeyboardShortcutHelpSections,
} from "@/keyboard/keyboard-shortcuts";
import { filterShortcutHelpSections } from "@/keyboard/shortcut-help-search";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
const SNAP_POINTS: string[] = ["70%", "92%"];
function shortcutSearchAliases(keys: string[], shortcutOs: "mac" | "non-mac"): string {
const aliases = keys.map((key) => {
if (shortcutOs === "mac") {
if (key === "mod" || key === "meta") return ["cmd", "command"];
if (key === "alt") return ["alt", "option"];
} else {
if (key === "mod" || key === "ctrl") return ["ctrl", "control"];
if (key === "meta") return ["win", "windows"];
}
return [key];
});
const combinations = aliases.reduce<string[][]>(
(prefixes, choices) =>
prefixes.flatMap((prefix) => choices.map((choice) => [...prefix, choice])),
[[]],
);
return combinations
.flatMap((combination) => [combination.join(" "), combination.join("+")])
.join(" ");
}
export function KeyboardShortcutsDialog() {
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
@@ -54,33 +33,10 @@ export function KeyboardShortcutsDialog() {
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }, bindings),
[bindings, isDesktopApp, isMac],
);
const visibleSections = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery) return sections;
return sections.flatMap((section) => {
const sectionTitle = t(section.titleKey);
if (sectionTitle.toLocaleLowerCase().includes(normalizedQuery)) {
return [section];
}
const rows = section.rows.filter((row) => {
const searchText = [
t(row.labelKey),
row.noteKey ? t(row.noteKey) : row.note,
row.keys.join(" "),
formatShortcut(row.keys, shortcutOs),
shortcutSearchAliases(row.keys, shortcutOs),
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase();
return searchText.includes(normalizedQuery);
});
return rows.length > 0 ? [{ ...section, rows }] : [];
});
}, [query, sections, shortcutOs, t]);
const visibleSections = useMemo(
() => filterShortcutHelpSections({ sections, query, translate: t, shortcutOs }),
[query, sections, shortcutOs, t],
);
useEffect(() => {
if (!open) setQuery("");
@@ -114,14 +70,18 @@ export function KeyboardShortcutsDialog() {
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
<View style={styles.rows}>
{section.rows.map((row) => (
<View key={row.id} style={styles.row}>
<View key={row.id} style={styles.row} testID={`shortcut-help-row-${row.id}`}>
<View style={styles.rowText}>
<Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
{row.note ? (
<Text style={styles.rowNote}>{row.noteKey ? t(row.noteKey) : row.note}</Text>
) : null}
</View>
<Shortcut keys={row.keys} style={styles.rowShortcut} />
{row.chord === null ? (
<Text style={styles.rowUnassigned}>{t("settings.shortcuts.unassigned")}</Text>
) : (
<Shortcut chord={row.chord} style={styles.rowShortcut} />
)}
</View>
))}
</View>
@@ -179,6 +139,13 @@ const styles = StyleSheet.create((theme) => ({
rowShortcut: {
alignSelf: "flex-start",
},
// Matches the settings row's unassigned label, so the two screens describe the
// same state the same way.
rowUnassigned: {
alignSelf: "flex-start",
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
empty: {
paddingVertical: theme.spacing[6],
textAlign: "center",
+1 -1
View File
@@ -1806,7 +1806,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View key={node.key} style={styles.list_item} dataSet={markdownCopyDataSet.li}>
<Text style={iconStyle} dataSet={markdownCopyDataSet.ignore}>
<Text style={iconStyle} dataSet={markdownCopyDataSet.listMarker}>
{marker}
</Text>
<MarkdownListItemContent contentStyle={contentStyle}>
@@ -0,0 +1,55 @@
import { Buffer } from "buffer";
import { type ReactElement, useMemo } from "react";
import { Image, type ImageStyle, type StyleProp, View } from "react-native";
import { SvgCss } from "react-native-svg/css";
const SVG_MIME_TYPE = "image/svg+xml";
const ICO_MIME_TYPES = new Set(["image/x-icon", "image/vnd.microsoft.icon"]);
const SVG_CONTAINER = { overflow: "hidden" } as const;
export interface ProjectIconImageProps {
dataUri: string;
fallback: ReactElement;
style: StyleProp<ImageStyle>;
}
function parseDataUri(dataUri: string): { mimeType: string; payload: string } | null {
const match = /^data:([^;,]+);base64,(.*)$/s.exec(dataUri);
if (!match) return null;
const [, mimeType, payload] = match;
return mimeType && payload ? { mimeType: mimeType.toLowerCase(), payload } : null;
}
function ignoreSvgParseError() {
// Invalid repository SVGs use the same fallback as unsupported icon formats.
}
export function ProjectIconImage({ dataUri, fallback, style }: ProjectIconImageProps) {
const source = useMemo(() => ({ uri: dataUri }), [dataUri]);
const parts = useMemo(() => parseDataUri(dataUri), [dataUri]);
const svgXml = useMemo(
() =>
parts?.mimeType === SVG_MIME_TYPE
? Buffer.from(parts.payload, "base64").toString("utf8")
: null,
[parts],
);
const svgContainerStyle = useMemo(() => [style, SVG_CONTAINER], [style]);
if (svgXml) {
return (
<View style={svgContainerStyle}>
<SvgCss
xml={svgXml}
width="100%"
height="100%"
fallback={fallback}
onError={ignoreSvgParseError}
/>
</View>
);
}
if (parts && ICO_MIME_TYPES.has(parts.mimeType)) return fallback;
return <Image source={source} style={style} />;
}
@@ -0,0 +1,8 @@
import { useMemo } from "react";
import { Image } from "react-native";
import type { ProjectIconImageProps } from "@/components/project-icon-image";
export function ProjectIconImage({ dataUri, style }: ProjectIconImageProps) {
const source = useMemo(() => ({ uri: dataUri }), [dataUri]);
return <Image source={source} style={style} />;
}
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Image, type StyleProp, Text, type TextStyle, View } from "react-native";
import { type StyleProp, Text, type TextStyle, View } from "react-native";
import { ProjectIconImage } from "@/components/project-icon-image";
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
const WHITE_TEXT = { color: "#ffffff" } as const;
@@ -28,6 +29,7 @@ export function projectIconRadius(size: number): number {
* width/height/radius/centering block, which is how the radius drifted apart in the first
* place — pass a `size` and the shape follows.
*/
export function ProjectIconView({
iconDataUri,
initial,
@@ -41,7 +43,6 @@ export function ProjectIconView({
size: number;
textStyle: StyleProp<TextStyle>;
}) {
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
// The uploaded image is sized but never clipped — see projectIconRadius.
const box = useMemo(() => ({ width: size, height: size }), [size]);
const fallbackStyles = useMemo(
@@ -55,12 +56,18 @@ export function ProjectIconView({
);
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
if (iconDataUri) {
return <Image source={imageSource} style={box} />;
}
return (
<View style={fallbackStyles}>
<Text style={textStyles}>{initial}</Text>
</View>
const fallback = useMemo(
() => (
<View style={fallbackStyles}>
<Text style={textStyles}>{initial}</Text>
</View>
),
[fallbackStyles, initial, textStyles],
);
return iconDataUri ? (
<ProjectIconImage dataUri={iconDataUri} fallback={fallback} style={box} />
) : (
fallback
);
}
@@ -376,18 +376,21 @@ function projectKebabStyle({
function getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -871,6 +874,7 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const localDaemonServerId = useLocalDaemonServerId();
@@ -917,6 +921,17 @@ function ProjectHeaderRow({
setContextMenuOpen(open);
if (open) setIsHovered(false);
}, []);
const handleProjectPressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleProjectPressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const projectRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
@@ -937,7 +952,7 @@ function ProjectHeaderRow({
iconDataUri={iconDataUri}
statusBucket={statusBucket}
projectViewKey={project.viewKey}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -982,10 +997,10 @@ function ProjectHeaderRow({
<PressHighlight
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowHovered}
onPressIn={interaction.handlePressIn}
highlightStyle={styles.projectRowPressed}
onPressIn={handleProjectPressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleProjectPressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1008,10 +1023,10 @@ function ProjectHeaderRow({
enabledOnMobile={false}
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowHovered}
onPressIn={interaction.handlePressIn}
highlightStyle={styles.projectRowPressed}
onPressIn={handleProjectPressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleProjectPressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1064,6 +1079,7 @@ function WorkspaceRowInner({
reserveIdleStatusIndicatorSpace = true,
}: WorkspaceRowInnerProps) {
const _isCompact = useIsCompactFormFactor();
const [isPressed, setIsPressed] = useState(false);
const isTouchPlatform = platformIsNative;
const interaction = useLongPressDragInteraction({
drag,
@@ -1083,6 +1099,17 @@ function WorkspaceRowInner({
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleWorkspacePressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
@@ -1093,6 +1120,7 @@ function WorkspaceRowInner({
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
@@ -1127,10 +1155,10 @@ function WorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowHovered}
onPressIn={interaction.handlePressIn}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handleWorkspacePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleWorkspacePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1140,7 +1168,7 @@ function WorkspaceRowInner({
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -12,8 +12,13 @@ import {
} from "@/utils/project-status-badge-content";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { STATUS_INDICATOR_ALERT_SIZE } from "@/utils/status-indicator-geometry";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import {
STATUS_INDICATOR_ALERT_SIZE,
STATUS_INDICATOR_FILLED_DOT_SIZE,
} from "@/utils/status-indicator-geometry";
import { StatusRing } from "@/components/status-ring";
import { getStatusRingOffset } from "@/components/status-ring/geometry";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
// Every surfaced status shares one badge shell, so the badge never changes size or position
// between states. Only the thing inside it changes.
@@ -29,7 +34,6 @@ const STATUS_BADGE_OFFSET = -4;
//
// The filled alert occupies the full badge shell so needs-input remains more prominent than
// the passive status dots.
const STATUS_BADGE_DOT_SIZE = 6;
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
@@ -65,7 +69,7 @@ export function ProjectLeadingVisual({
statusBucket: SidebarStateBucket | null;
projectViewKey: string;
/** The row's current background, so the status badge can knock out of it. */
backdrop: SurfaceBackdrop;
backdrop: SidebarSurfaceBackdrop;
chevron?: "expand" | "collapse" | null;
showChevron?: boolean;
isArchiving?: boolean;
@@ -116,7 +120,7 @@ export function ProjectStatusIndicator({
projectViewKey: string;
statusBucket: SidebarStateBucket | null;
/** The row's current background, so the status badge can knock out of it. */
backdrop: SurfaceBackdrop;
backdrop: SidebarSurfaceBackdrop;
loading?: boolean;
testID?: string;
}) {
@@ -164,8 +168,23 @@ function ProjectStatusBadge({
}: {
content: ProjectStatusBadgeContent;
statusBucket: SidebarStateBucket;
backdrop: SurfaceBackdrop;
backdrop: SidebarSurfaceBackdrop;
}) {
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
// so nesting it inside would clip it against the very thing that was meant to separate it from
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
if (content.kind === "dot" && content.bucket === "running") {
return (
<View
role="status"
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
style={styles.statusRingAnchor}
testID="project-status-badge"
>
<StatusRing backdrop={backdrop} />
</View>
);
}
return (
<View
role="status"
@@ -182,14 +201,14 @@ function ProjectStatusBadge({
);
}
function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
function getStatusBadgeBackdropStyle(backdrop: SidebarSurfaceBackdrop): ViewStyle {
switch (backdrop) {
case "surfaceSidebar":
return styles.statusBadgeOnSidebar;
case "surfaceSidebarHover":
return styles.statusBadgeOnSidebarHover;
case "surface2":
return styles.statusBadgeOnSurface2;
default:
return styles.statusBadgeOnSidebar;
}
}
@@ -240,8 +259,8 @@ const styles = StyleSheet.create((theme) => {
// so this badge can't drift from the status dots everywhere else.
const statusDot = (bucket: ProjectStatusBadgeDotBucket) =>
({
width: STATUS_BADGE_DOT_SIZE,
height: STATUS_BADGE_DOT_SIZE,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined,
}) as const;
@@ -282,6 +301,12 @@ const styles = StyleSheet.create((theme) => {
justifyContent: "center",
overflow: "hidden",
},
// Same corner as the shell, re-centred for the wider ring.
statusRingAnchor: {
position: "absolute",
right: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE),
bottom: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE),
},
statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar },
statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 },
@@ -1,4 +1,4 @@
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
/**
* Which surface a sidebar row is currently painting, so anything knocking out of it — the status
@@ -9,19 +9,19 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
* here rather than three ternaries beside three stylesheets: drifting from them is how the badge
* ends up with a halo, and a halo in one grouping mode but not the other is worse than either.
*
* The pressed background is deliberately absent. It lives in a Pressable style callback and lasts
* as long as a finger is down, which is not long enough to see a mismatched ring.
*/
export function getSidebarRowBackdrop({
isDragging = false,
isPressed = false,
selected = false,
isHovered = false,
}: {
isDragging?: boolean;
isPressed?: boolean;
selected?: boolean;
isHovered?: boolean;
}): SurfaceBackdrop {
if (isDragging) return "surface2";
}): SidebarSurfaceBackdrop {
if (isDragging || isPressed) return "surface2";
if (selected || isHovered) return "surfaceSidebarHover";
return "surfaceSidebar";
}
@@ -687,12 +687,15 @@ function StatusWorkspaceRowInner({
inStatusGroup?: boolean;
}) {
const isTouchPlatform = platformIsNative;
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const handlePressIn = useCallback(() => setIsPressed(true), []);
const handlePressOut = useCallback(() => setIsPressed(false), []);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
@@ -713,6 +716,7 @@ function StatusWorkspaceRowInner({
showShortcut,
});
const workspaceRowStyle = getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
@@ -743,7 +747,9 @@ function StatusWorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowHovered}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -753,7 +759,7 @@ function StatusWorkspaceRowInner({
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
@@ -857,10 +863,12 @@ function StatusWorkspaceActionSlot({
}
function getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
@@ -870,6 +878,7 @@ function getStatusWorkspaceRowStyle({
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isPressed && styles.workspaceRowPressed,
];
}
@@ -4,7 +4,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import {
WorkspaceMetaRow,
type WorkspaceServiceSummary,
@@ -19,12 +19,14 @@ import {
import { useAppSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { getStatusDotColor } from "@/utils/status-dot-color";
import {
STATUS_INDICATOR_ALERT_SIZE,
STATUS_INDICATOR_DOT_SIZE,
STATUS_INDICATOR_FILLED_DOT_SIZE,
} from "@/utils/status-indicator-geometry";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
@@ -32,11 +34,6 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
const SCRIM_WIDTH = 48;
const SCRIM_SOLID_OFFSET = "55%";
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({
color: theme.colors.surface0,
@@ -143,7 +140,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
leadingProjectIconDataUri?: string | null;
serviceSummary?: WorkspaceServiceSummary | null;
/** The row's current background, so the project status badge can knock out of it. */
backdrop: SurfaceBackdrop;
backdrop: SidebarSurfaceBackdrop;
isHovered: boolean;
isLoading: boolean;
isCreating?: boolean;
@@ -221,14 +218,14 @@ function WorkspaceStatusIndicator({
loading?: boolean;
reserveIdleSpace?: boolean;
}) {
// Busy is a dot here for the same reason it is on a project icon: every status in the
// sidebar is a dot, and a row with a project icon simply moves that dot onto the icon.
// A row starting up and a row working are both busy, so they share the dot and differ only
// in testID.
// Busy is the only status that moves, and it is the ring rather than a dot for the same
// reason it is a dot elsewhere: every status in the sidebar sits in this one slot, so busy
// has to fill it without displacing anything. A row starting up and a row working are both
// busy, so they share the ring and differ only in testID.
if (loading) {
return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
<View style={styles.standaloneRunningDot} />
<StatusRing />
</View>
);
}
@@ -236,7 +233,7 @@ function WorkspaceStatusIndicator({
if (shouldRenderSyncedStatusLoader({ bucket })) {
return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
<View style={styles.standaloneRunningDot} />
<StatusRing />
</View>
);
}
@@ -275,50 +272,16 @@ function WorkspaceStatusIndicator({
else KindIcon = ThemedFolder;
const dotColorStyle = getStatusDotColorStyle(bucket);
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
) : null}
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
</View>
);
}
function StatusDotOverlay({
dotColorStyle,
size,
offset,
}: {
dotColorStyle: ViewStyle;
size: number;
offset: number;
}) {
const overlayStyle = useMemo(
() => [
styles.statusDotOverlay,
dotColorStyle,
{
width: size,
height: size,
right: offset,
bottom: offset,
},
],
[dotColorStyle, offset, size],
);
return <View style={overlayStyle} />;
function StatusDotOverlay({ dotColorStyle }: { dotColorStyle: ViewStyle }) {
return <View style={[styles.statusDotOverlay, dotColorStyle]} />;
}
function getStatusDotColorStyle(bucket: SidebarStateBucket) {
@@ -569,24 +532,22 @@ const styles = StyleSheet.create((theme) => ({
},
statusDotOverlay: {
position: "absolute",
right: 0,
bottom: 0,
width: STATUS_INDICATOR_DOT_SIZE,
height: STATUS_INDICATOR_DOT_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
standaloneStatusDot: {
width: STATUS_INDICATOR_DOT_SIZE,
height: STATUS_INDICATOR_DOT_SIZE,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
},
standaloneRunningDot: {
width: STATUS_INDICATOR_DOT_SIZE,
height: STATUS_INDICATOR_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
},
idleStatusDot: {
width: 8,
height: 8,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
opacity: 0.3,
@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text } from "react-native";
import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
@@ -264,6 +264,7 @@ function WorkspaceRowBody({
archiveShortcutKeys,
}: WorkspaceRowBodyProps) {
const isTouchPlatform = platformIsNative;
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const draggable = Boolean(drag);
const interaction = useLongPressDragInteraction({
@@ -284,6 +285,17 @@ function WorkspaceRowBody({
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
if (draggable) interaction.handlePressIn(event);
},
[draggable, interaction],
);
const handleWorkspacePressOut = useCallback(() => {
setIsPressed(false);
if (draggable) interaction.handlePressOut();
}, [draggable, interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
@@ -292,7 +304,12 @@ function WorkspaceRowBody({
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
const workspaceRowStyle = getWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
return (
<View
{...(draggable ? dragAttributes : {})}
@@ -325,10 +342,10 @@ function WorkspaceRowBody({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowHovered}
onPressIn={draggable ? interaction.handlePressIn : undefined}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handleWorkspacePressIn}
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
onPressOut={draggable ? interaction.handlePressOut : undefined}
onPressOut={handleWorkspacePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -336,7 +353,7 @@ function WorkspaceRowBody({
workspace={workspace}
hostBadge={hostBadge}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -458,18 +475,21 @@ function WorkspaceRowTrailingActions({
function getWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -495,6 +515,9 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
@@ -710,7 +710,7 @@ function DragOverlayTabChipInner({
return (
<View style={chipStyle}>
<WorkspaceTabIcon presentation={presentation} active size={14} />
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
<Text numberOfLines={1} style={chipLabelStyle}>
{label}
</Text>
@@ -0,0 +1,58 @@
import { useLayoutEffect } from "react";
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
import { scheduleOnUI } from "react-native-worklets";
import { getStatusRingRotation } from "@/components/status-ring/geometry";
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
const activeRingCount = makeMutable(0);
const clockRunning = makeMutable(false);
function advanceSharedRotation(): void {
"worklet";
if (activeRingCount.value === 0) {
clockRunning.value = false;
return;
}
sharedRotation.value = getStatusRingRotation(Date.now());
requestAnimationFrame(advanceSharedRotation);
}
function registerStatusRing(registered: SharedValue<boolean>): void {
"worklet";
if (registered.value) {
return;
}
registered.value = true;
activeRingCount.value += 1;
if (!clockRunning.value) {
clockRunning.value = true;
sharedRotation.value = getStatusRingRotation(Date.now());
requestAnimationFrame(advanceSharedRotation);
}
}
function unregisterStatusRing(registered: SharedValue<boolean>): void {
"worklet";
if (!registered.value) {
return;
}
registered.value = false;
activeRingCount.value -= 1;
}
export function useStatusRingRotation(): SharedValue<number> {
const registered = useSharedValue(false);
useLayoutEffect(() => {
scheduleOnUI(registerStatusRing, registered);
return () => {
scheduleOnUI(unregisterStatusRing, registered);
};
}, [registered]);
return sharedRotation;
}
@@ -0,0 +1,35 @@
import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
import type { View as NativeView } from "react-native";
import { STATUS_RING_PERIOD_MS } from "@/components/status-ring/geometry";
const STATUS_RING_KEYFRAMES: PropertyIndexedKeyframes = {
transform: ["rotate(0deg)", "rotate(360deg)"],
};
const STATUS_RING_TIMING: KeyframeAnimationOptions = {
duration: STATUS_RING_PERIOD_MS,
easing: "linear",
iterations: Number.POSITIVE_INFINITY,
};
const STATUS_RING_TIMELINE_START_MS = 0;
export function useStatusRingAnimationRef(): RefCallback<NativeView> {
const arcElement = useRef<HTMLElement | null>(null);
const setArcElement = useCallback((instance: NativeView | null) => {
arcElement.current = instance instanceof HTMLElement ? instance : null;
}, []);
useLayoutEffect(() => {
const element = arcElement.current;
if (!element) {
return;
}
const animation = element.animate(STATUS_RING_KEYFRAMES, STATUS_RING_TIMING);
animation.startTime = STATUS_RING_TIMELINE_START_MS;
return () => {
animation.cancel();
};
}, []);
return setArcElement;
}
@@ -0,0 +1,121 @@
import type { ReactNode } from "react";
import { StyleSheet as RNStyleSheet, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { getStatusDotColor } from "@/utils/status-dot-color";
import {
STATUS_RING_FRAME_SIZE,
STATUS_RING_HEAD_OPACITY,
STATUS_RING_SIZE,
STATUS_RING_STROKE,
STATUS_RING_TRACK_OPACITY,
} from "@/components/status-ring/geometry";
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
export interface StatusRingProps {
// The surface the ring is drawn on top of, named rather than resolved, because theme colours
// are only legible inside `StyleSheet.create` — see docs/unistyles.md. The frame fills itself
// with this so the ring reads as a hole punched in whatever it overlaps: the agent icon under a
// tab dot, the project tile under a sidebar badge. Leave it out where the ring sits on flat
// background and has nothing to knock out.
backdrop?: SurfaceBackdrop | null;
}
/**
* The static half of the running indicator: the knockout, the track, and the centre dot. The
* platform entry points supply the rotating quarter and own only how it is driven.
*
* The ring has no colour prop. It only ever means one thing — an agent is running — so it reads
* that one colour out of the theme itself. Passing the colour in would materialise it at the call
* site, outside anything Unistyles tracks, and leave the mark in the old theme until something
* incidental re-rendered the row.
*/
export function StatusRingFrame({ backdrop, children }: StatusRingProps & { children: ReactNode }) {
return (
<View style={[styles.frame, getBackdropStyle(backdrop)]}>
<View style={styles.track} />
{children}
<View style={styles.centerDot} />
</View>
);
}
function getBackdropStyle(backdrop: SurfaceBackdrop | null | undefined) {
switch (backdrop) {
case "surface0":
return styles.backdropSurface0;
case "surface1":
return styles.backdropSurface1;
case "surfaceSidebar":
return styles.backdropSurfaceSidebar;
case "surfaceSidebarHover":
return styles.backdropSurfaceSidebarHover;
case "surface2":
return styles.backdropSurface2;
default:
return null;
}
}
// The rotating quarter's geometry, deliberately plain React Native rather than Unistyles: on
// native this style lands on a Reanimated `Animated.View`, and applying a theme-tracked style to
// one crashes when the theme changes — Unistyles and Reanimated both mutate the same native node.
// See docs/unistyles.md. The colour it needs lives on `styles.arc`, one level in, on a view
// Reanimated never touches.
export const rotatorStyles = RNStyleSheet.create({
rotator: {
position: "absolute",
width: STATUS_RING_SIZE,
height: STATUS_RING_SIZE,
},
});
export const styles = StyleSheet.create((theme) => {
const circle = {
width: STATUS_RING_SIZE,
height: STATUS_RING_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: STATUS_RING_STROKE,
} as const;
const runningColor = getStatusDotColor({ theme, bucket: "running" }) ?? undefined;
return {
frame: {
width: STATUS_RING_FRAME_SIZE,
height: STATUS_RING_FRAME_SIZE,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
backdropSurface0: { backgroundColor: theme.colors.surface0 },
backdropSurface1: { backgroundColor: theme.colors.surface1 },
backdropSurfaceSidebar: { backgroundColor: theme.colors.surfaceSidebar },
backdropSurfaceSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
backdropSurface2: { backgroundColor: theme.colors.surface2 },
// The closed ring the quarter runs on. Same colour rather than a grey so the indicator is one
// object at one hue; the opacity is what puts it behind the quarter instead of competing.
track: {
...circle,
position: "absolute",
borderColor: runningColor,
opacity: STATUS_RING_TRACK_OPACITY,
},
// The moving quarter. Three sides transparent leaves the top border alone, which on a fully
// rounded box is a 90° arc — the edge that makes the rotation legible against the track.
arc: {
...circle,
borderColor: "transparent",
borderTopColor: runningColor,
opacity: STATUS_RING_HEAD_OPACITY,
},
centerDot: {
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: runningColor,
},
};
});
@@ -0,0 +1,49 @@
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
// The running indicator is a status dot with a rotating ring around it, and the whole set of
// numbers below is a budget rather than four independent choices. The centre matches every other
// filled status circle; the gap and stroke grow outward from it without changing that shared size.
export const STATUS_RING_GAP = 1.5;
export const STATUS_RING_STROKE = 1.5;
// Derived outward from the dot, which means the dot is not a free variable: shrink it without
// opening the gap by the same amount and the ring closes in behind it, the whole mark scales
// down, and nothing about how the centre reads against the ring actually changes.
export const STATUS_RING_SIZE =
STATUS_INDICATOR_FILLED_DOT_SIZE + (STATUS_RING_GAP + STATUS_RING_STROKE) * 2;
// The knockout margin, and the reason the frame is wider than the ring. These indicators sit on
// the bottom-right corner of an agent or project icon, and a dot only stays readable there
// because it carries a ring of surface colour separating it from the art underneath. A ring drawn
// out to the frame's edge has no such separation and collides with the icon, so the frame is the
// knockout and the ring lives inside it.
export const STATUS_RING_HALO_INSET = 1;
export const STATUS_RING_FRAME_SIZE = STATUS_RING_SIZE + STATUS_RING_HALO_INSET * 2;
// The track and the quarter riding it are the same color and differ only here. The track is low
// enough that the quarter is unmistakably the thing moving, high enough that the ring still reads
// as a ring when the quarter is on the far side of it. The quarter is held off full so the centre
// dot stays the solid part of the mark — the ring is what the dot is doing, not a second dot.
export const STATUS_RING_TRACK_OPACITY = 0.3;
export const STATUS_RING_HEAD_OPACITY = 0.9;
// One period for every ring in the app. Each platform owns one mount-independent timeline: web
// animations share an absolute document-timeline start, while native publishes this wall-clock
// phase from one UI-thread clock. A row that appears mid-flight therefore lands in step with the
// rings already turning.
export const STATUS_RING_PERIOD_MS = 900;
export function getStatusRingRotation(nowMs: number): number {
"worklet";
return ((nowMs % STATUS_RING_PERIOD_MS) / STATUS_RING_PERIOD_MS) * 360;
}
/**
* Re-anchors a corner status dot's offset for the ring that replaces it.
*
* The dots this replaces are pinned to an icon's bottom-right corner rather than centred in a
* slot, so growing them in place would walk the indicator off the corner. Callers keep their
* existing dot offset and pass it through here to grow around the same centre point.
*/
export function getStatusRingOffset(dotOffset: number, dotSize: number): number {
return dotOffset - (STATUS_RING_FRAME_SIZE - dotSize) / 2;
}
@@ -0,0 +1,32 @@
import { memo } from "react";
import { View } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import {
StatusRingFrame,
type StatusRingProps,
rotatorStyles,
styles,
} from "@/components/status-ring/frame";
import { useStatusRingRotation } from "@/components/status-ring/clock";
/**
* Native running indicator. The rotation is published by one shared UI-thread clock rather than
* per-instance timing, so a ring that mounts mid-flight is already in phase — see `clock.ts`.
*
* The rotated view carries no theme-tracked style; the coloured arc is nested inside it. Putting
* a Unistyles style on a Reanimated view crashes on theme change (docs/unistyles.md).
*/
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
const rotation = useStatusRingRotation();
const rotatorStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<StatusRingFrame backdrop={backdrop}>
<Animated.View style={[rotatorStyles.rotator, rotatorStyle]}>
<View style={styles.arc} />
</Animated.View>
</StatusRingFrame>
);
});
@@ -0,0 +1,26 @@
import { memo } from "react";
import { View } from "react-native";
import {
StatusRingFrame,
type StatusRingProps,
rotatorStyles,
styles,
} from "@/components/status-ring/frame";
import { useStatusRingAnimationRef } from "@/components/status-ring/clock.web";
/**
* Web running indicator. A sidebar full of agents can hold dozens of these at once, so the browser
* drives the rotation from one absolute document-timeline epoch rather than from mount time — see
* `clock.web.ts`.
*/
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
const rotatorRef = useStatusRingAnimationRef();
return (
<StatusRingFrame backdrop={backdrop}>
<View ref={rotatorRef} style={rotatorStyles.rotator}>
<View style={styles.arc} />
</View>
</StatusRingFrame>
);
});
@@ -1,11 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Image, Text, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { ProjectIconView } from "@/components/project-icon-view";
import { ICON_SIZE } from "@/styles/theme";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIcon } from "@/projects/icons";
@@ -29,7 +31,6 @@ import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import type { MessagePayload } from "@/composer/types";
import { projectIconRadius } from "@/components/project-icon-view";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -381,7 +382,6 @@ export function WorkspaceSetupDialog() {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const agentControlsWithDisabled = useMemo(
() =>
composerState
@@ -396,19 +396,19 @@ export function WorkspaceSetupDialog() {
const subtitleContent = useMemo(
() => (
<View style={styles.subtitleRow}>
{iconSource ? (
<Image source={iconSource} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectViewKey={sourceDirectory}
size={ICON_SIZE.md}
textStyle={styles.projectIconFallbackText}
/>
<Text style={styles.projectTitle} numberOfLines={1}>
{workspaceTitle}
</Text>
</View>
),
[iconSource, placeholderInitial, workspaceTitle],
[iconDataUri, placeholderInitial, sourceDirectory, workspaceTitle],
);
const sheetHeader = useMemo<SheetHeader>(
@@ -461,22 +461,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
// A user's uploaded mark is sized, never clipped — see projectIconRadius.
projectIcon: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
projectIconFallback: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: projectIconRadius(theme.iconSize.md),
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: {
color: theme.colors.foregroundMuted,
fontSize: 9,
},
projectTitle: {
@@ -1,8 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
createDesktopLocalDaemonTransportFactory,
createDesktopWebSocketTransportFactory,
} from "./desktop-daemon-transport";
import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
@@ -46,25 +43,4 @@ describe("desktop-daemon-transport", () => {
expect(rpc.closedSessions).toEqual(["local-session-2"]);
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("forwards remote WebSocket headers and protocols through the desktop bridge", () => {
const rpc = createFakeLocalDaemonTransportRpc();
const transportFactory = createDesktopWebSocketTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
transportFactory!({
url: "wss://daemon.example/ws",
headers: { "X-Tenant": "acme", Authorization: "Bearer secret" },
protocols: ["paseo.bearer.secret"],
});
expect(rpc.openCalls).toEqual([
{
transportType: "websocket",
url: "wss://daemon.example/ws",
headers: { "X-Tenant": "acme", Authorization: "Bearer secret" },
protocols: ["paseo.bearer.secret"],
},
]);
});
});
@@ -2,13 +2,11 @@ import type {
DaemonTransport,
DaemonTransportFactory,
} from "@getpaseo/client/internal/daemon-client";
import type { DesktopDaemonTransportTarget, LocalTransportTarget } from "./desktop-daemon";
import type { LocalTransportTarget } from "./desktop-daemon";
import {
defaultLocalDaemonTransportRpc,
defaultWebSocketDaemonTransportRpc,
type LocalDaemonTransportRpc,
} from "./local-daemon-transport-rpc";
import { isElectronRuntime } from "@/desktop/host";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
@@ -52,12 +50,11 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
};
}
function createDesktopDaemonTransportFactory(
resolveTarget: (options: Parameters<DaemonTransportFactory>[0]) => DesktopDaemonTransportTarget,
rpc: LocalDaemonTransportRpc,
): DaemonTransportFactory {
return (options) => {
const target = resolveTarget(options);
export function createDesktopLocalDaemonTransportFactory(
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
let unlisten: (() => void) | null = null;
let disposed = false;
@@ -190,26 +187,3 @@ function createDesktopDaemonTransportFactory(
return transport;
};
}
export function createDesktopLocalDaemonTransportFactory(
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
): DaemonTransportFactory | null {
return createDesktopDaemonTransportFactory(({ url }) => parseLocalDaemonTransportUrl(url), rpc);
}
export function createDesktopWebSocketTransportFactory(
rpc?: LocalDaemonTransportRpc,
): DaemonTransportFactory | null {
if (!rpc && !isElectronRuntime()) {
return null;
}
return createDesktopDaemonTransportFactory(
({ url, headers, protocols }) => ({
transportType: "websocket" as const,
url,
...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
...(protocols && protocols.length > 0 ? { protocols } : {}),
}),
rpc ?? defaultWebSocketDaemonTransportRpc,
);
}
@@ -46,16 +46,6 @@ export interface LocalTransportTarget {
transportPath: string;
}
export interface WebSocketTransportTarget {
[key: string]: unknown;
transportType: "websocket";
url: string;
headers?: Record<string, string>;
protocols?: string[];
}
export type DesktopDaemonTransportTarget = LocalTransportTarget | WebSocketTransportTarget;
interface LocalTransportEventPayload {
sessionId: string;
kind: "open" | "message" | "close" | "error";
@@ -203,16 +193,6 @@ export async function openLocalTransportSession(target: LocalTransportTarget): P
return raw;
}
export async function openWebSocketTransportSession(
target: WebSocketTransportTarget,
): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("open_websocket_daemon_transport", target);
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("Unexpected WebSocket transport session response.");
}
return raw;
}
export async function sendLocalTransportMessage(input: {
sessionId: string;
text?: string;
@@ -2,10 +2,8 @@ import {
closeLocalTransportSession,
listenToLocalTransportEvents,
openLocalTransportSession,
openWebSocketTransportSession,
sendLocalTransportMessage,
type DesktopDaemonTransportTarget,
type WebSocketTransportTarget,
type LocalTransportTarget,
} from "./desktop-daemon";
export interface LocalDaemonTransportEvent {
@@ -19,7 +17,7 @@ export interface LocalDaemonTransportEvent {
}
export interface LocalDaemonTransportRpc {
openSession(target: DesktopDaemonTransportTarget): Promise<string>;
openSession(target: LocalTransportTarget): Promise<string>;
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
closeSession(sessionId: string): Promise<void>;
@@ -31,10 +29,3 @@ export const defaultLocalDaemonTransportRpc: LocalDaemonTransportRpc = {
sendMessage: sendLocalTransportMessage,
closeSession: closeLocalTransportSession,
};
export const defaultWebSocketDaemonTransportRpc: LocalDaemonTransportRpc = {
openSession: (target) => openWebSocketTransportSession(target as WebSocketTransportTarget),
listenToEvents: listenToLocalTransportEvents,
sendMessage: sendLocalTransportMessage,
closeSession: closeLocalTransportSession,
};
@@ -1,4 +1,4 @@
import type { DesktopDaemonTransportTarget } from "./desktop-daemon";
import type { LocalTransportTarget } from "./desktop-daemon";
import type {
LocalDaemonTransportEvent,
LocalDaemonTransportRpc,
@@ -11,7 +11,7 @@ export interface RecordedSend {
}
export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
readonly openCalls: DesktopDaemonTransportTarget[];
readonly openCalls: LocalTransportTarget[];
readonly recordedSends: RecordedSend[];
readonly closedSessions: string[];
resolveOpen(sessionId: string): void;
@@ -22,7 +22,7 @@ export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
}
export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc {
const openCalls: DesktopDaemonTransportTarget[] = [];
const openCalls: LocalTransportTarget[] = [];
const recordedSends: RecordedSend[] = [];
const closedSessions: string[] = [];
let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
@@ -201,7 +201,9 @@ export function useKeyboardShortcuts({
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
}
}
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
useKeyboardShortcutsStore
.getState()
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
return true;
}
case "shortcuts-dialog-toggle":
+5 -1
View File
@@ -190,7 +190,11 @@ describe("translation resources", () => {
expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar");
expect(en.shell.menu.open).toBe("Open menu");
expect(en.shell.menu.close).toBe("Close menu");
expect(en.shell.commandCenter.placeholder).toBe("Search commands, workspaces, and agents...");
expect(en.shell.commandCenter.placeholder).toBe(
"Search commands, files, workspaces, and agents...",
);
expect(en.shell.commandCenter.filePlaceholder).toBe("Search files...");
expect(en.shell.commandCenter.files).toBe("Files");
expect(en.shell.commandCenter.noMatches).toBe("No matches");
expect(en.shell.commandCenter.actions).toBe("Actions");
expect(en.shell.commandCenter.workspaces).toBe("Workspaces");
+8 -17
View File
@@ -54,9 +54,12 @@ export const ar: TranslationResources = {
close: "إغلاق القائمة",
},
commandCenter: {
placeholder: "ابحث في الأوامر ومساحات العمل والوكلاء...",
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
filePlaceholder: "البحث في الملفات...",
searchingFiles: "جارٍ البحث في الملفات...",
noMatches: "لا توجد مباريات",
actions: "الإجراءات",
files: "الملفات",
workspaces: "مساحات العمل",
agents: "الوكلاء",
newAgent: "وكيل جديد",
@@ -1426,19 +1429,6 @@ export const ar: TranslationResources = {
show: "عرض متقدم",
hide: "إخفاء المتقدمة",
},
headers: {
title: "رؤوس مخصصة",
add: "إضافة رأس",
name: "الاسم",
value: "القيمة",
remove: "إزالة الرأس",
errors: {
missingName: "أدخل اسمًا لكل رأس",
invalidName: "{{name}} ليس اسم رأس صالحًا",
invalidValue: "يحتوي {{name}} على فاصل أسطر غير صالح",
duplicateName: "تم إدخال {{name}} أكثر من مرة",
},
},
passwordVisibility: {
show: "إظهار كلمة المرور",
hide: "إخفاء كلمة المرور",
@@ -1925,10 +1915,10 @@ export const ar: TranslationResources = {
resetAll: "إعادة ضبط الكل",
},
sections: {
navigation: "ملاحة",
general: "عام",
workspaces: "المشاريع ومساحات العمل",
tabsPanes: "علامات التبويب والأجزاء",
projects: "المشاريع",
panels: "لوحات",
layout: "التخطيط",
agentInput: "إدخال Agent",
},
help: {
@@ -1956,6 +1946,7 @@ export const ar: TranslationResources = {
moveTabDown: "حرك علامة التبويب لأسفل",
closePane: "إغلاق الجزء",
newTerminal: "محطة جديدة",
searchFiles: "البحث في الملفات",
toggleCommandCenter: "تبديل مركز القيادة",
showKeyboardShortcuts: "إظهار اختصارات لوحة المفاتيح",
toggleLeftSidebar: "تبديل الشريط الجانبي الأيسر",
+8 -17
View File
@@ -52,9 +52,12 @@ export const en = {
close: "Close menu",
},
commandCenter: {
placeholder: "Search commands, workspaces, and agents...",
placeholder: "Search commands, files, workspaces, and agents...",
filePlaceholder: "Search files...",
searchingFiles: "Searching files...",
noMatches: "No matches",
actions: "Actions",
files: "Files",
workspaces: "Workspaces",
agents: "Agents",
newAgent: "New agent",
@@ -1437,19 +1440,6 @@ export const en = {
show: "Show advanced",
hide: "Hide advanced",
},
headers: {
title: "Custom headers",
add: "Add header",
name: "Name",
value: "Value",
remove: "Remove header",
errors: {
missingName: "Enter a name for each header",
invalidName: "{{name}} is not a valid header name",
invalidValue: "{{name}} contains an invalid line break",
duplicateName: "{{name}} is entered more than once",
},
},
passwordVisibility: {
show: "Show password",
hide: "Hide password",
@@ -1935,10 +1925,10 @@ export const en = {
resetAll: "Reset all",
},
sections: {
navigation: "Navigation",
general: "General",
workspaces: "Projects & Workspaces",
tabsPanes: "Tabs & Panes",
projects: "Projects",
panels: "Panels",
layout: "Layout",
agentInput: "Agent Input",
},
help: {
@@ -1966,6 +1956,7 @@ export const en = {
moveTabDown: "Move tab down",
closePane: "Close pane",
newTerminal: "New terminal",
searchFiles: "Search files",
toggleCommandCenter: "Toggle command center",
showKeyboardShortcuts: "Show keyboard shortcuts",
toggleLeftSidebar: "Toggle left sidebar",
+8 -17
View File
@@ -54,9 +54,12 @@ export const es: TranslationResources = {
close: "Cerrar menú",
},
commandCenter: {
placeholder: "Buscar comandos, espacios de trabajo y agentes...",
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
filePlaceholder: "Buscar archivos...",
searchingFiles: "Buscando archivos...",
noMatches: "No hay coincidencias",
actions: "Comportamiento",
files: "Archivos",
workspaces: "Espacios de trabajo",
agents: "Agentes",
newAgent: "Nuevo agente",
@@ -1467,19 +1470,6 @@ export const es: TranslationResources = {
show: "Mostrar avanzado",
hide: "Ocultar avanzado",
},
headers: {
title: "Encabezados personalizados",
add: "Añadir encabezado",
name: "Nombre",
value: "Valor",
remove: "Eliminar encabezado",
errors: {
missingName: "Introduce un nombre para cada encabezado",
invalidName: "{{name}} no es un nombre de encabezado válido",
invalidValue: "{{name}} contiene un salto de línea no válido",
duplicateName: "{{name}} se ha introducido más de una vez",
},
},
passwordVisibility: {
show: "Mostrar contraseña",
hide: "Ocultar contraseña",
@@ -1973,10 +1963,10 @@ export const es: TranslationResources = {
resetAll: "Restablecer todo",
},
sections: {
navigation: "Navegación",
general: "General",
workspaces: "Proyectos y espacios de trabajo",
tabsPanes: "Pestañas y paneles",
projects: "Proyectos",
panels: "Paneles",
layout: "Diseño",
agentInput: "EntradaAgent",
},
help: {
@@ -2004,6 +1994,7 @@ export const es: TranslationResources = {
moveTabDown: "Mover pestaña hacia abajo",
closePane: "Cerrar panel",
newTerminal: "Nueva terminal",
searchFiles: "Buscar archivos",
toggleCommandCenter: "Alternar centro de comando",
showKeyboardShortcuts: "Mostrar atajos de teclado",
toggleLeftSidebar: "Alternar barra lateral izquierda",
+8 -17
View File
@@ -55,9 +55,12 @@ export const fr: TranslationResources = {
close: "Fermer le menu",
},
commandCenter: {
placeholder: "Rechercher des commandes, espaces de travail et agents...",
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...",
filePlaceholder: "Rechercher des fichiers...",
searchingFiles: "Recherche de fichiers...",
noMatches: "Aucune correspondance",
actions: "Actes",
files: "Fichiers",
workspaces: "Espaces de travail",
agents: "Agents",
newAgent: "Nouvel agent",
@@ -1471,19 +1474,6 @@ export const fr: TranslationResources = {
show: "Afficher avancé",
hide: "Masquer avancé",
},
headers: {
title: "En-têtes personnalisés",
add: "Ajouter un en-tête",
name: "Nom",
value: "Valeur",
remove: "Supprimer l'en-tête",
errors: {
missingName: "Saisissez un nom pour chaque en-tête",
invalidName: "{{name}} n'est pas un nom d'en-tête valide",
invalidValue: "{{name}} contient un saut de ligne non valide",
duplicateName: "{{name}} est saisi plusieurs fois",
},
},
passwordVisibility: {
show: "Afficher le mot de passe",
hide: "Masquer le mot de passe",
@@ -1976,10 +1966,10 @@ export const fr: TranslationResources = {
resetAll: "Tout réinitialiser",
},
sections: {
navigation: "Navigation",
general: "Général",
workspaces: "Projets et espaces de travail",
tabsPanes: "Onglets et volets",
projects: "Projets",
panels: "Panneaux",
layout: "Disposition",
agentInput: "EntréeAgent",
},
help: {
@@ -2007,6 +1997,7 @@ export const fr: TranslationResources = {
moveTabDown: "Déplacer l'onglet vers le bas",
closePane: "Fermer le volet",
newTerminal: "Nouvelle borne",
searchFiles: "Rechercher des fichiers",
toggleCommandCenter: "Basculer le centre de commande",
showKeyboardShortcuts: "Afficher les raccourcis clavier",
toggleLeftSidebar: "Basculer la barre latérale gauche",
+8 -17
View File
@@ -54,9 +54,12 @@ export const ja: TranslationResources = {
close: "メニューを閉じる",
},
commandCenter: {
placeholder: "コマンド、ワークスペース、エージェントを検索...",
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
filePlaceholder: "ファイルを検索...",
searchingFiles: "ファイルを検索中...",
noMatches: "一致なし",
actions: "アクション",
files: "ファイル",
workspaces: "ワークスペース",
agents: "エージェント",
newAgent: "新しいエージェント",
@@ -1440,19 +1443,6 @@ export const ja: TranslationResources = {
show: "詳細設定を表示",
hide: "詳細設定を非表示",
},
headers: {
title: "カスタムヘッダー",
add: "ヘッダーを追加",
name: "名前",
value: "値",
remove: "ヘッダーを削除",
errors: {
missingName: "各ヘッダーの名前を入力してください",
invalidName: "{{name}} は有効なヘッダー名ではありません",
invalidValue: "{{name}} に無効な改行が含まれています",
duplicateName: "{{name}} が複数回入力されています",
},
},
passwordVisibility: {
show: "パスワードを表示",
hide: "パスワードを非表示",
@@ -1941,10 +1931,10 @@ export const ja: TranslationResources = {
resetAll: "すべてリセット",
},
sections: {
navigation: "ナビゲーション",
general: "一般",
workspaces: "プロジェクト&ワークスペース",
tabsPanes: "タブ&ペイン",
projects: "プロジェクト",
panels: "パネル",
layout: "レイアウト",
agentInput: "エージェント入力",
},
help: {
@@ -1972,6 +1962,7 @@ export const ja: TranslationResources = {
moveTabDown: "タブを下に移動",
closePane: "ペインを閉じる",
newTerminal: "新しいターミナル",
searchFiles: "ファイルを検索",
toggleCommandCenter: "コマンドセンターを切り替え",
showKeyboardShortcuts: "キーボードショートカットを表示",
toggleLeftSidebar: "左サイドバーを切り替え",
+8 -17
View File
@@ -54,9 +54,12 @@ export const ko: TranslationResources = {
close: "메뉴 닫기",
},
commandCenter: {
placeholder: "명령, 워크스페이스 및 에이전트 검색...",
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
filePlaceholder: "파일 검색...",
searchingFiles: "파일 검색 중...",
noMatches: "일치 항목 없음",
actions: "작업",
files: "파일",
workspaces: "워크스페이스",
agents: "에이전트",
newAgent: "새 에이전트",
@@ -1436,19 +1439,6 @@ export const ko: TranslationResources = {
show: "고급 표시",
hide: "고급 숨기기",
},
headers: {
title: "사용자 지정 헤더",
add: "헤더 추가",
name: "이름",
value: "값",
remove: "헤더 삭제",
errors: {
missingName: "각 헤더의 이름을 입력하세요",
invalidName: "{{name}}은(는) 올바른 헤더 이름이 아닙니다",
invalidValue: "{{name}}에 잘못된 줄바꿈이 있습니다",
duplicateName: "{{name}}이(가) 두 번 이상 입력되었습니다",
},
},
passwordVisibility: {
show: "비밀번호 표시",
hide: "비밀번호 숨기기",
@@ -1936,10 +1926,10 @@ export const ko: TranslationResources = {
resetAll: "모두 재설정",
},
sections: {
navigation: "탐색",
general: "일반",
workspaces: "프로젝트 및 워크스페이스",
tabsPanes: "탭 및 창",
projects: "프로젝트",
panels: "패널",
layout: "레이아웃",
agentInput: "에이전트 입력",
},
help: {
@@ -1967,6 +1957,7 @@ export const ko: TranslationResources = {
moveTabDown: "탭을 아래로 이동",
closePane: "창 닫기",
newTerminal: "새 터미널",
searchFiles: "파일 검색",
toggleCommandCenter: "명령 센터 토글",
showKeyboardShortcuts: "키보드 단축키 표시",
toggleLeftSidebar: "왼쪽 사이드바 토글",
+8 -17
View File
@@ -54,9 +54,12 @@ export const ptBR: TranslationResources = {
close: "Fechar menu",
},
commandCenter: {
placeholder: "Buscar comandos, espaços de trabalho e agentes...",
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
filePlaceholder: "Pesquisar arquivos...",
searchingFiles: "Pesquisando arquivos...",
noMatches: "Nenhuma correspondência",
actions: "Ações",
files: "Arquivos",
workspaces: "Espaços de trabalho",
agents: "Agentes",
newAgent: "Novo agente",
@@ -1453,19 +1456,6 @@ export const ptBR: TranslationResources = {
show: "Mostrar avançado",
hide: "Ocultar avançado",
},
headers: {
title: "Cabeçalhos personalizados",
add: "Adicionar cabeçalho",
name: "Nome",
value: "Valor",
remove: "Remover cabeçalho",
errors: {
missingName: "Digite um nome para cada cabeçalho",
invalidName: "{{name}} não é um nome de cabeçalho válido",
invalidValue: "{{name}} contém uma quebra de linha inválida",
duplicateName: "{{name}} foi inserido mais de uma vez",
},
},
passwordVisibility: {
show: "Mostrar senha",
hide: "Ocultar senha",
@@ -1956,10 +1946,10 @@ export const ptBR: TranslationResources = {
resetAll: "Redefinir tudo",
},
sections: {
navigation: "Navegação",
general: "Geral",
workspaces: "Projetos e workspaces",
tabsPanes: "Abas e painéis",
projects: "Projetos",
panels: "Painéis",
layout: "Layout",
agentInput: "Entrada do agente",
},
help: {
@@ -1987,6 +1977,7 @@ export const ptBR: TranslationResources = {
moveTabDown: "Mover aba para baixo",
closePane: "Fechar painel",
newTerminal: "Novo terminal",
searchFiles: "Pesquisar arquivos",
toggleCommandCenter: "Alternar central de comandos",
showKeyboardShortcuts: "Mostrar atalhos de teclado",
toggleLeftSidebar: "Alternar barra lateral esquerda",
+8 -17
View File
@@ -54,9 +54,12 @@ export const ru: TranslationResources = {
close: "Закрыть меню",
},
commandCenter: {
placeholder: "Поиск команд, рабочих пространств и агентов...",
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
filePlaceholder: "Поиск файлов...",
searchingFiles: "Поиск файлов...",
noMatches: "Нет совпадений",
actions: "Действия",
files: "Файлы",
workspaces: "Рабочие пространства",
agents: "Агенты",
newAgent: "Новый агент",
@@ -1458,19 +1461,6 @@ export const ru: TranslationResources = {
show: "Показать расширенные",
hide: "Скрыть расширенные",
},
headers: {
title: "Пользовательские заголовки",
add: "Добавить заголовок",
name: "Имя",
value: "Значение",
remove: "Удалить заголовок",
errors: {
missingName: "Введите имя для каждого заголовка",
invalidName: "{{name}} — недопустимое имя заголовка",
invalidValue: "{{name}} содержит недопустимый перенос строки",
duplicateName: "{{name}} введён несколько раз",
},
},
passwordVisibility: {
show: "Показать пароль",
hide: "Скрыть пароль",
@@ -1963,10 +1953,10 @@ export const ru: TranslationResources = {
resetAll: "Сбросить все",
},
sections: {
navigation: "Навигация",
general: "Общие",
workspaces: "Проекты и рабочие пространства",
tabsPanes: "Вкладки и панели",
projects: "Проекты",
panels: "Панели",
layout: "Макет",
agentInput: "Вход Agent",
},
help: {
@@ -1994,6 +1984,7 @@ export const ru: TranslationResources = {
moveTabDown: "Переместить вкладку вниз",
closePane: "Закрыть панель",
newTerminal: "Новый терминал",
searchFiles: "Поиск файлов",
toggleCommandCenter: "Переключить командный центр",
showKeyboardShortcuts: "Показать сочетания клавиш",
toggleLeftSidebar: "Переключить левую боковую панель",
+8 -17
View File
@@ -54,9 +54,12 @@ export const zhCN: TranslationResources = {
close: "关闭菜单",
},
commandCenter: {
placeholder: "搜索命令、工作区和 Agent...",
placeholder: "搜索命令、文件、工作区和 Agent...",
filePlaceholder: "搜索文件...",
searchingFiles: "正在搜索文件...",
noMatches: "没有匹配项",
actions: "操作",
files: "文件",
workspaces: "工作区",
agents: "Agents",
newAgent: "新建 Agent",
@@ -1409,19 +1412,6 @@ export const zhCN: TranslationResources = {
show: "显示高级选项",
hide: "隐藏高级选项",
},
headers: {
title: "自定义请求头",
add: "添加请求头",
name: "名称",
value: "值",
remove: "删除请求头",
errors: {
missingName: "请为每个请求头输入名称",
invalidName: "{{name}} 不是有效的请求头名称",
invalidValue: "{{name}} 包含无效换行符",
duplicateName: "{{name}} 输入了多次",
},
},
passwordVisibility: {
show: "显示密码",
hide: "隐藏密码",
@@ -1903,10 +1893,10 @@ export const zhCN: TranslationResources = {
resetAll: "全部重置",
},
sections: {
navigation: "导航",
general: "通用",
workspaces: "项目和工作区",
tabsPanes: "标签和窗格",
projects: "项目",
panels: "面板",
layout: "布局",
agentInput: "Agent 输入",
},
help: {
@@ -1934,6 +1924,7 @@ export const zhCN: TranslationResources = {
moveTabDown: "向下移动标签",
closePane: "关闭窗格",
newTerminal: "新建终端",
searchFiles: "搜索文件",
toggleCommandCenter: "切换命令中心",
showKeyboardShortcuts: "显示键盘快捷键",
toggleLeftSidebar: "切换左侧边栏",
+1
View File
@@ -41,6 +41,7 @@ export type KeyboardActionId =
| "sidebar.toggle.both"
| "settings.toggle"
| "command-center.toggle"
| "command-center.files"
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"
| "workspace.new"
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { formatShortcut } from "@/utils/format-shortcut";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
@@ -8,6 +9,7 @@ import {
parseBindingChord,
resolveKeyboardShortcut,
resolveShortcutKeysForAction,
SHORTCUT_HELP_ROW_ORDER,
UNASSIGNED_COMBO,
type ChordState,
type KeyboardShortcutContext,
@@ -148,16 +150,16 @@ describe("keyboard-shortcuts", () => {
action: "workspace.new",
},
{
name: "matches Cmd+P to switch project on mac",
name: "matches Cmd+P to search workspace files on mac",
event: { key: "p", code: "KeyP", metaKey: true },
context: { isMac: true, commandCenterOpen: false },
action: "workspace.project.pick",
action: "command-center.files",
},
{
name: "matches Ctrl+P to switch project on non-mac",
name: "matches Ctrl+P to search workspace files on non-mac",
event: { key: "p", code: "KeyP", ctrlKey: true },
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
action: "workspace.project.pick",
action: "command-center.files",
},
{
name: "matches question-mark shortcut to toggle the shortcuts dialog",
@@ -645,7 +647,11 @@ describe("keyboard-shortcut help sections", () => {
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"],
// Derived from `combo: "Cmd+W"`, so the token is `mod` where the row
// used to be hand-authored as `meta`. This binding is mac-only and
// `formatShortcut` renders both as ⌘, so the badge is unchanged — see
// the render assertion below.
"workspace-tab-close-current": ["mod", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
@@ -659,11 +665,15 @@ describe("keyboard-shortcut help sections", () => {
},
},
{
name: "uses mod+b for the left sidebar and mod+period for both sidebars on non-mac",
name: "uses ctrl+b for the left sidebar and ctrl+period for both sidebars on non-mac",
context: { isMac: false, isDesktop: false },
// Derived from `combo: "Ctrl+B"` / `"Ctrl+."`, so the token is `ctrl` where
// these rows used to be hand-authored as `mod`. Both bindings are non-mac
// only and `formatShortcut` labels either token "Ctrl" there, so the badge
// is unchanged — see the render assertion below.
expectedKeys: {
"toggle-left-sidebar": ["mod", "B"],
"toggle-both-sidebars": ["mod", "."],
"toggle-left-sidebar": ["ctrl", "B"],
"toggle-both-sidebars": ["ctrl", "."],
},
},
];
@@ -672,26 +682,163 @@ describe("keyboard-shortcut help sections", () => {
const sections = buildKeyboardShortcutHelpSections(context);
for (const [id, keys] of Object.entries(expectedKeys)) {
expect(findRow(sections, id)?.keys).toEqual(keys);
expect(findRow(sections, id)?.chord).toEqual([keys]);
}
});
describe("rows derive their keys from the binding that fires", () => {
const macDesktop = { isMac: true, isDesktop: true };
const NEW_WORKSPACE_BINDING = "workspace-new-cmd-n-mac";
const MAC_INDEX_BINDING = "workspace-navigate-index-cmd-digit-mac";
const PANE_FOCUS_LEFT_BINDING = "workspace-pane-focus-left-cmd-shift-left";
const SHOW_SHORTCUTS_BINDING = "shortcuts-dialog-toggle-question-mark";
function rowChord(overrides: ShortcutOverrides, id: string) {
const sections = buildKeyboardShortcutHelpSections(
macDesktop,
buildEffectiveBindings(overrides),
);
return findRow(sections, id)?.chord ?? null;
}
// The reported bug: the cheat sheet advertised the shipped default no matter
// what the user had rebound the shortcut to.
it("shows the override, not the shipped default", () => {
expect(rowChord({}, "new-workspace")).toEqual([["mod", "N"]]);
expect(rowChord({ [NEW_WORKSPACE_BINDING]: "Cmd+Shift+K" }, "new-workspace")).toEqual([
["mod", "shift", "K"],
]);
});
it("shows every step of a multi-step chord override", () => {
expect(rowChord({ [NEW_WORKSPACE_BINDING]: "Cmd+K Cmd+N" }, "new-workspace")).toEqual([
["mod", "K"],
["mod", "N"],
]);
});
it("reports an unassigned row as having no keys at all", () => {
expect(rowChord({ [NEW_WORKSPACE_BINDING]: UNASSIGNED_COMBO }, "new-workspace")).toBeNull();
});
it("returns to the shipped default once the override is cleared", () => {
expect(rowChord({ [NEW_WORKSPACE_BINDING]: "Cmd+Shift+K" }, "new-workspace")).toEqual([
["mod", "shift", "K"],
]);
expect(rowChord({}, "new-workspace")).toEqual([["mod", "N"]]);
});
// An arrow override used to render as the raw `ARROWLEFT` code, because the
// display path uppercased key names past the table that maps them to arrows.
it("renders an arrow override as an arrow", () => {
const chord = rowChord(
{ [PANE_FOCUS_LEFT_BINDING]: "Cmd+Alt+ArrowLeft" },
"workspace-pane-focus-left",
);
expect(chord).toEqual([["mod", "alt", "Left"]]);
expect(formatShortcut(chord?.[0] ?? [], "mac")).toBe("⌥⌘←");
});
// `1-9` and `?` are display-only tokens no combo string can spell, so these
// two rows opt out of default derivation via `help.defaultDisplayKeys`.
it("keeps the wildcard token on the index-jump row", () => {
expect(rowChord({}, "workspace-jump-index")).toEqual([["mod", "1-9"]]);
});
it("keeps the bare ? on the show-shortcuts row rather than deriving Shift+?", () => {
expect(rowChord({}, "show-shortcuts")).toEqual([["?"]]);
});
it("replaces the default-only wildcard when the index jump is rebound", () => {
expect(rowChord({ [MAC_INDEX_BINDING]: "Ctrl+Digit" }, "workspace-jump-index")).toEqual([
["ctrl", "Digit"],
]);
});
it("replaces the default-only ? when show shortcuts is rebound", () => {
expect(rowChord({ [SHOW_SHORTCUTS_BINDING]: "Cmd+K" }, "show-shortcuts")).toEqual([
["mod", "K"],
]);
});
// The authored row said `meta`, the derived row says `mod`. Both render ⌘ on
// mac and this binding is mac-only, so nothing the user sees moved.
it("still renders close-tab as ⌘W after switching to the derived token", () => {
const sections = buildKeyboardShortcutHelpSections(macDesktop);
const chord = findRow(sections, "workspace-tab-close-current")?.chord;
expect(formatShortcut(chord?.[0] ?? [], "mac")).toBe("⌘W");
});
// Same story on the other side: these rows said `mod`, the non-mac combos
// say `Ctrl`, and non-mac labels both as "Ctrl".
it("still renders the sidebar toggles as Ctrl+B and Ctrl+. on non-mac", () => {
const sections = buildKeyboardShortcutHelpSections({ isMac: false, isDesktop: false });
const left = findRow(sections, "toggle-left-sidebar")?.chord;
const both = findRow(sections, "toggle-both-sidebars")?.chord;
expect(formatShortcut(left?.[0] ?? [], "non-mac")).toBe("Ctrl+B");
expect(formatShortcut(both?.[0] ?? [], "non-mac")).toBe("Ctrl+.");
});
});
it("returns stable i18n keys for section titles and help rows", () => {
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
const projects = sections.find((section) => section.id === "projects");
const panels = sections.find((section) => section.id === "panels");
const workspaces = sections.find((section) => section.id === "workspaces");
const layout = sections.find((section) => section.id === "layout");
const openProject = findRow(sections, "new-agent");
const cycleAgentMode = findRow(sections, "cycle-agent-mode");
const showShortcuts = findRow(sections, "show-shortcuts");
expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects");
expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels");
expect(workspaces?.titleKey).toBe("settings.shortcuts.sections.workspaces");
expect(layout?.titleKey).toBe("settings.shortcuts.sections.layout");
expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject");
expect(openProject?.label).toBe("Open project");
expect(cycleAgentMode?.labelKey).toBe("settings.shortcuts.help.cycleAgentMode");
expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts");
});
it("gives every help row an explicit place in its section's order", () => {
const platforms = [
{ isMac: true, isDesktop: true },
{ isMac: false, isDesktop: true },
{ isMac: true, isDesktop: false },
{ isMac: false, isDesktop: false },
];
const unplaced: string[] = [];
for (const platform of platforms) {
for (const section of buildKeyboardShortcutHelpSections(platform)) {
for (const row of section.rows) {
if (SHORTCUT_HELP_ROW_ORDER[section.id].includes(row.id)) continue;
unplaced.push(`${section.id}:${row.id}`);
}
}
}
expect(unplaced).toEqual([]);
});
it("leads the general section with the command center and file search", () => {
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
expect(sections[0]?.id).toBe("general");
expect(sections[0]?.rows.slice(0, 2).map((row) => row.id)).toEqual([
"toggle-command-center",
"search-files",
]);
});
it("reuses the project-picker binding ids for rebindable file search", () => {
expect(getBindingIdForAction("search-files", { isMac: true, isDesktop: true })).toBe(
"workspace-project-pick-cmd-p-mac",
);
expect(getBindingIdForAction("search-files", { isMac: false, isDesktop: true })).toBe(
"workspace-project-pick-ctrl-p-non-mac",
);
expect(
findRow(buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }), "search-files")
?.chord,
).not.toBeNull();
});
it("does not expose Enter send behavior as rebindable shortcut rows", () => {
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
@@ -889,9 +1036,12 @@ describe("unassigned shortcuts", () => {
});
describe("resolveShortcutKeysForAction", () => {
// `ctrl`, not `mod`: these keys are derived from the non-mac binding's
// `combo: "Ctrl+T"` rather than hand-authored. `formatShortcut` labels both
// tokens "Ctrl" off mac, and this binding is non-mac only.
it("returns the default keys when there is no override", () => {
expect(resolveShortcutKeysForAction("workspace-tab-new", {}, desktopNonMac)).toEqual([
["mod", "T"],
["ctrl", "T"],
]);
});
@@ -924,13 +1074,13 @@ describe("unassigned shortcuts", () => {
{ [TAB_NEW_BINDING]: "Ctrl+Nonsense" },
desktopNonMac,
),
).toEqual([["mod", "T"]]);
).toEqual([["ctrl", "T"]]);
});
it("falls back to the default keys for a non-string stored value", () => {
const overrides = { [TAB_NEW_BINDING]: 42 } as unknown as ShortcutOverrides;
expect(resolveShortcutKeysForAction("workspace-tab-new", overrides, desktopNonMac)).toEqual([
["mod", "T"],
["ctrl", "T"],
]);
});
@@ -944,7 +1094,7 @@ describe("unassigned shortcuts", () => {
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
const sections = buildKeyboardShortcutHelpSections(desktopNonMac, bindings);
expect(findRow(sections, "workspace-tab-new")?.keys).toEqual([]);
expect(findRow(sections, "workspace-tab-new")?.chord).toBeNull();
});
it("keeps the row so the shortcut stays rebindable", () => {
@@ -958,15 +1108,14 @@ describe("unassigned shortcuts", () => {
it("still lists the default keys when nothing is overridden", () => {
const sections = buildKeyboardShortcutHelpSections(desktopNonMac);
expect(findRow(sections, "workspace-tab-new")?.keys).toEqual(["mod", "T"]);
expect(getDefaultKeysForAction("workspace-tab-new", desktopNonMac)).toEqual(["mod", "T"]);
expect(findRow(sections, "workspace-tab-new")?.chord).toEqual([["ctrl", "T"]]);
expect(getDefaultKeysForAction("workspace-tab-new", desktopNonMac)).toEqual([["ctrl", "T"]]);
});
// A binding authored with `combo: ""` has no default. The settings row uses
// this to hide Reset, which would otherwise be a dead button landing on the
// same "Not set" state Clear already produced. `help.keys` stays populated
// on purpose: it is hand-authored, so the empty parsed chord alone has to be
// what marks the binding default-less.
// same "Not set" state Clear already produced. The empty parsed chord is what
// marks the binding default-less.
it("reports no default keys for a binding that ships without a combo", () => {
const bindings = withoutDefaultCombo("workspace-tab-new");
@@ -977,7 +1126,7 @@ describe("unassigned shortcuts", () => {
const bindings = withoutDefaultCombo("workspace-tab-new");
const sections = buildKeyboardShortcutHelpSections(desktopNonMac, bindings);
expect(findRow(sections, "workspace-tab-new")?.keys).toEqual([]);
expect(findRow(sections, "workspace-tab-new")?.chord).toBeNull();
});
});
});
+173 -152
View File
@@ -43,12 +43,13 @@ export interface KeyboardShortcutHelpRow {
id: string;
label: string;
labelKey: string;
keys: ShortcutKey[];
/** The keys that actually fire this action, or `null` when it has none. */
chord: ShortcutKey[][] | null;
note?: string;
noteKey?: string;
}
export type ShortcutSectionId = "navigation" | "tabs-panes" | "projects" | "panels" | "agent-input";
export type ShortcutSectionId = "general" | "workspaces" | "tabs-panes" | "layout" | "agent-input";
export interface KeyboardShortcutHelpSection {
id: ShortcutSectionId;
@@ -88,7 +89,14 @@ interface ShortcutHelp {
id: string;
section: ShortcutSectionId;
label: string;
keys: ShortcutKey[];
/**
* Display keys to show instead of the combo. Set this only when the combo
* cannot express what the row means — the `Digit` wildcard, which stands for
* any of 1-9, and `Shift+?`, whose Shift is implied by the character itself.
* Every other row derives its keys from `combo`, so a rebound shortcut shows
* what it now does rather than what it shipped as.
*/
defaultDisplayKeys?: ShortcutKey[];
note?: string;
}
@@ -116,22 +124,81 @@ export interface ChordState {
// --- Constants ---
// Sections are listed in this order, most-reached-for first.
const SHORTCUT_HELP_SECTION_ORDER: readonly ShortcutSectionId[] = [
"general",
"workspaces",
"tabs-panes",
"layout",
"agent-input",
];
const SHORTCUT_HELP_SECTION_TITLES: Record<ShortcutSectionId, string> = {
navigation: "Navigation",
general: "General",
workspaces: "Projects & Workspaces",
"tabs-panes": "Tabs & Panes",
projects: "Projects",
panels: "Panels",
layout: "Layout",
"agent-input": "Agent Input",
};
const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
navigation: "settings.shortcuts.sections.navigation",
general: "settings.shortcuts.sections.general",
workspaces: "settings.shortcuts.sections.workspaces",
"tabs-panes": "settings.shortcuts.sections.tabsPanes",
projects: "settings.shortcuts.sections.projects",
panels: "settings.shortcuts.sections.panels",
layout: "settings.shortcuts.sections.layout",
"agent-input": "settings.shortcuts.sections.agentInput",
};
// Rows render in this order rather than in binding-definition order, so the shortcut someone opens
// this sheet to find sits at the top of its section. Every help id must appear here; the unit test
// fails when a new shortcut is added without a place, instead of silently sinking to the bottom.
export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[]> = {
general: [
"toggle-command-center",
"search-files",
"show-shortcuts",
"toggle-settings",
"cycle-theme",
],
workspaces: [
"new-agent",
"new-workspace",
"workspace-jump-index",
"workspace-prev",
"workspace-next",
"pin-workspace",
"archive-workspace",
],
"tabs-panes": [
"workspace-tab-new",
"workspace-terminal-new",
"workspace-tab-close-current",
"workspace-tab-jump-index",
"workspace-tab-prev",
"workspace-tab-next",
"workspace-pane-split-right",
"workspace-pane-split-down",
"workspace-pane-focus-left",
"workspace-pane-focus-right",
"workspace-pane-focus-up",
"workspace-pane-focus-down",
"workspace-pane-move-tab-left",
"workspace-pane-move-tab-right",
"workspace-pane-move-tab-up",
"workspace-pane-move-tab-down",
"workspace-pane-close",
],
layout: ["toggle-left-sidebar", "toggle-right-sidebar", "toggle-both-sidebars", "toggle-focus"],
"agent-input": [
"focus-message-input",
"cycle-agent-mode",
"voice-toggle",
"dictation-toggle",
"agent-interrupt",
"voice-mute-toggle",
],
};
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"new-agent": "settings.shortcuts.help.openProject",
"new-workspace": "settings.shortcuts.help.newWorkspace",
@@ -157,6 +224,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"workspace-pane-move-tab-down": "settings.shortcuts.help.moveTabDown",
"workspace-pane-close": "settings.shortcuts.help.closePane",
"workspace-terminal-new": "settings.shortcuts.help.newTerminal",
"search-files": "settings.shortcuts.help.searchFiles",
"toggle-command-center": "settings.shortcuts.help.toggleCommandCenter",
"show-shortcuts": "settings.shortcuts.help.showKeyboardShortcuts",
"toggle-left-sidebar": "settings.shortcuts.help.toggleLeftSidebar",
@@ -192,9 +260,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "new-agent",
section: "projects",
section: "workspaces",
label: "Open project",
keys: ["mod", "O"],
},
},
{
@@ -204,9 +271,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "new-agent",
section: "projects",
section: "workspaces",
label: "Open project",
keys: ["mod", "O"],
},
},
@@ -218,9 +284,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "new-workspace",
section: "projects",
section: "workspaces",
label: "New workspace",
keys: ["mod", "N"],
},
},
{
@@ -230,35 +295,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "new-workspace",
section: "projects",
section: "workspaces",
label: "New workspace",
keys: ["mod", "N"],
},
},
// --- Switch project (New Workspace screen) ---
// --- Search files (switch project on the New Workspace screen) ---
{
id: "workspace-project-pick-cmd-p-mac",
action: "workspace.project.pick",
action: "command-center.files",
combo: "Cmd+P",
when: { mac: true, commandCenter: false },
help: {
id: "switch-project",
section: "projects",
label: "Switch project",
keys: ["mod", "P"],
id: "search-files",
section: "general",
label: "Search files",
},
},
{
id: "workspace-project-pick-ctrl-p-non-mac",
action: "workspace.project.pick",
action: "command-center.files",
combo: "Ctrl+P",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "switch-project",
section: "projects",
label: "Switch project",
keys: ["mod", "P"],
id: "search-files",
section: "general",
label: "Search files",
},
},
@@ -272,9 +334,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "archive-workspace",
section: "projects",
section: "workspaces",
label: "Archive workspace",
keys: ["mod", "shift", "Backspace"],
},
},
{
@@ -286,9 +347,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "archive-workspace",
section: "projects",
section: "workspaces",
label: "Archive workspace",
keys: ["mod", "shift", "Backspace"],
},
},
@@ -300,9 +360,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "pin-workspace",
section: "projects",
section: "workspaces",
label: "Pin chat",
keys: ["mod", "shift", "P"],
},
},
{
@@ -312,9 +371,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "pin-workspace",
section: "projects",
section: "workspaces",
label: "Pin chat",
keys: ["mod", "shift", "P"],
},
},
@@ -328,7 +386,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-tab-new",
section: "tabs-panes",
label: "New tab",
keys: ["mod", "T"],
},
},
{
@@ -340,7 +397,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-tab-new",
section: "tabs-panes",
label: "New tab",
keys: ["mod", "T"],
},
},
{
@@ -352,7 +408,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-tab-close-current",
section: "tabs-panes",
label: "Close current tab",
keys: ["meta", "W"],
},
},
{
@@ -364,7 +419,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-tab-close-current",
section: "tabs-panes",
label: "Close current tab",
keys: ["ctrl", "W"],
},
},
{
@@ -376,7 +430,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-tab-close-current",
section: "tabs-panes",
label: "Close current tab",
keys: ["alt", "shift", "W"],
},
},
@@ -389,9 +442,9 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "workspaces",
label: "Jump to workspace",
keys: ["mod", "1-9"],
defaultDisplayKeys: ["mod", "1-9"],
},
},
{
@@ -402,9 +455,9 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "workspaces",
label: "Jump to workspace",
keys: ["mod", "1-9"],
defaultDisplayKeys: ["mod", "1-9"],
},
},
{
@@ -415,9 +468,9 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "workspaces",
label: "Jump to workspace",
keys: ["alt", "1-9"],
defaultDisplayKeys: ["alt", "1-9"],
},
},
@@ -430,9 +483,9 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "tabs-panes",
label: "Jump to tab",
keys: ["mod", "alt", "1-9"],
defaultDisplayKeys: ["mod", "alt", "1-9"],
},
},
{
@@ -443,9 +496,9 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "tabs-panes",
label: "Jump to tab",
keys: ["alt", "1-9"],
defaultDisplayKeys: ["alt", "1-9"],
},
},
{
@@ -456,9 +509,9 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "tabs-panes",
label: "Jump to tab",
keys: ["alt", "shift", "1-9"],
defaultDisplayKeys: ["alt", "shift", "1-9"],
},
},
@@ -471,9 +524,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "workspaces",
label: "Previous workspace",
keys: ["mod", "["],
},
},
{
@@ -484,9 +536,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "workspaces",
label: "Previous workspace",
keys: ["mod", "["],
},
},
{
@@ -497,9 +548,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "workspaces",
label: "Next workspace",
keys: ["mod", "]"],
},
},
{
@@ -510,9 +560,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "workspaces",
label: "Next workspace",
keys: ["mod", "]"],
},
},
{
@@ -523,9 +572,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "workspaces",
label: "Previous workspace",
keys: ["alt", "["],
},
},
{
@@ -536,9 +584,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "workspaces",
label: "Next workspace",
keys: ["alt", "]"],
},
},
@@ -551,9 +598,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
section: "navigation",
section: "tabs-panes",
label: "Previous tab",
keys: ["alt", "shift", "["],
},
},
{
@@ -564,9 +610,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
section: "navigation",
section: "tabs-panes",
label: "Next tab",
keys: ["alt", "shift", "]"],
},
},
@@ -580,7 +625,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-split-right",
section: "tabs-panes",
label: "Split pane right",
keys: ["mod", "\\"],
},
},
{
@@ -592,7 +636,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-split-down",
section: "tabs-panes",
label: "Split pane down",
keys: ["mod", "shift", "\\"],
},
},
{
@@ -604,7 +647,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-left",
section: "tabs-panes",
label: "Focus pane left",
keys: ["mod", "shift", "Left"],
},
},
{
@@ -616,7 +658,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-right",
section: "tabs-panes",
label: "Focus pane right",
keys: ["mod", "shift", "Right"],
},
},
{
@@ -628,7 +669,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-up",
section: "tabs-panes",
label: "Focus pane up",
keys: ["mod", "shift", "Up"],
},
},
{
@@ -640,7 +680,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-down",
section: "tabs-panes",
label: "Focus pane down",
keys: ["mod", "shift", "Down"],
},
},
{
@@ -652,7 +691,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-move-tab-left",
section: "tabs-panes",
label: "Move tab left",
keys: ["mod", "shift", "alt", "Left"],
},
},
{
@@ -664,7 +702,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-move-tab-right",
section: "tabs-panes",
label: "Move tab right",
keys: ["mod", "shift", "alt", "Right"],
},
},
{
@@ -676,7 +713,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-move-tab-up",
section: "tabs-panes",
label: "Move tab up",
keys: ["mod", "shift", "alt", "Up"],
},
},
{
@@ -688,7 +724,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-move-tab-down",
section: "tabs-panes",
label: "Move tab down",
keys: ["mod", "shift", "alt", "Down"],
},
},
{
@@ -700,7 +735,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-close",
section: "tabs-panes",
label: "Close pane",
keys: ["mod", "shift", "W"],
},
},
@@ -712,9 +746,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "workspace-terminal-new",
section: "panels",
section: "tabs-panes",
label: "New terminal",
keys: ["mod", "shift", "T"],
},
},
{
@@ -724,9 +757,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "workspace-terminal-new",
section: "panels",
section: "tabs-panes",
label: "New terminal",
keys: ["mod", "shift", "T"],
},
},
@@ -738,9 +770,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-command-center",
section: "panels",
section: "general",
label: "Toggle command center",
keys: ["mod", "K"],
},
},
{
@@ -750,9 +781,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "toggle-command-center",
section: "panels",
section: "general",
label: "Toggle command center",
keys: ["mod", "K"],
},
},
@@ -765,9 +795,9 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { focusScope: "other" },
help: {
id: "show-shortcuts",
section: "panels",
section: "general",
label: "Show keyboard shortcuts",
keys: ["?"],
defaultDisplayKeys: ["?"],
note: "Available when focus is not in a text field or terminal.",
},
},
@@ -780,9 +810,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-left-sidebar",
section: "panels",
section: "layout",
label: "Toggle left sidebar",
keys: ["mod", "B"],
},
},
{
@@ -792,9 +821,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-left-sidebar",
section: "panels",
section: "layout",
label: "Toggle left sidebar",
keys: ["mod", "B"],
},
},
{
@@ -804,9 +832,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "toggle-right-sidebar",
section: "panels",
section: "layout",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
},
{
@@ -816,9 +843,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-right-sidebar",
section: "panels",
section: "layout",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
},
{
@@ -836,9 +862,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "toggle-both-sidebars",
section: "panels",
section: "layout",
label: "Toggle both sidebars",
keys: ["mod", "."],
},
},
{
@@ -848,9 +873,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-both-sidebars",
section: "panels",
section: "layout",
label: "Toggle both sidebars",
keys: ["mod", "."],
},
},
@@ -862,9 +886,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "toggle-settings",
section: "panels",
section: "general",
label: "Toggle settings",
keys: ["mod", ","],
},
},
{
@@ -874,9 +897,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-settings",
section: "panels",
section: "general",
label: "Toggle settings",
keys: ["mod", ","],
},
},
@@ -888,9 +910,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "toggle-focus",
section: "panels",
section: "layout",
label: "Toggle focus mode",
keys: ["mod", "shift", "F"],
},
},
{
@@ -900,9 +921,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-focus",
section: "panels",
section: "layout",
label: "Toggle focus mode",
keys: ["mod", "shift", "F"],
},
},
@@ -914,9 +934,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "cycle-theme",
section: "panels",
section: "general",
label: "Cycle theme",
keys: ["mod", "alt", "T"],
},
},
{
@@ -926,9 +945,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "cycle-theme",
section: "panels",
section: "general",
label: "Cycle theme",
keys: ["mod", "alt", "T"],
},
},
@@ -943,7 +961,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "focus-message-input",
section: "agent-input",
label: "Focus message input",
keys: ["mod", "L"],
},
},
{
@@ -956,7 +973,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "focus-message-input",
section: "agent-input",
label: "Focus message input",
keys: ["mod", "L"],
},
},
{
@@ -970,7 +986,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "cycle-agent-mode",
section: "agent-input",
label: "Cycle agent mode",
keys: ["shift", "Tab"],
},
},
{
@@ -984,7 +999,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "voice-toggle",
section: "agent-input",
label: "Toggle voice mode",
keys: ["mod", "shift", "D"],
},
},
{
@@ -998,7 +1012,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "voice-toggle",
section: "agent-input",
label: "Toggle voice mode",
keys: ["mod", "shift", "D"],
},
},
{
@@ -1011,7 +1024,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "dictation-toggle",
section: "agent-input",
label: "Start/stop dictation",
keys: ["mod", "D"],
},
},
{
@@ -1024,7 +1036,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "dictation-toggle",
section: "agent-input",
label: "Start/stop dictation",
keys: ["mod", "D"],
},
},
{
@@ -1038,7 +1049,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "agent-interrupt",
section: "agent-input",
label: "Interrupt agent",
keys: ["Esc"],
},
},
{
@@ -1060,7 +1070,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "voice-mute-toggle",
section: "agent-input",
label: "Mute/unmute voice mode",
keys: ["Space"],
},
},
];
@@ -1123,7 +1132,11 @@ export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShor
if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
}
return { ...binding, combo: override, parsedChord };
if (!binding.help?.defaultDisplayKeys) {
return { ...binding, combo: override, parsedChord };
}
const { defaultDisplayKeys: _defaultDisplayKeys, ...help } = binding.help;
return { ...binding, combo: override, parsedChord, help };
});
}
@@ -1422,11 +1435,33 @@ export function getBindingIdForAction(
return null;
}
/**
* The keys to display for one binding, derived from the combo that actually
* fires so a rebound shortcut never advertises the keys it shipped with.
*
* `help.defaultDisplayKeys` wins where it is set, but only on the shipped
* binding. Effective bindings discard it when an override replaces the combo,
* so rebound rows still derive the keys that now fire.
* `parsedChord` is the single source of truth for "has no keys": it is derived
* from `combo`, so it covers both a user-unassigned shortcut and a binding
* authored without a default.
*/
function displayChordForBinding(binding: ParsedShortcutBinding): ShortcutKey[][] | null {
if (binding.parsedChord.length === 0) {
return null;
}
const displayKeys = binding.help?.defaultDisplayKeys;
if (displayKeys) {
return [displayKeys];
}
return chordStringToShortcutKeys(binding.combo);
}
export function getDefaultKeysForAction(
actionId: string,
platform: { isMac: boolean; isDesktop: boolean },
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
): ShortcutKey[] | null {
): ShortcutKey[][] | null {
for (const binding of bindings) {
if (binding.help?.id !== actionId) {
continue;
@@ -1434,13 +1469,7 @@ export function getDefaultKeysForAction(
if (!helpMatchesPlatform(binding.when, platform)) {
continue;
}
// `help.keys` is hand-authored, so it cannot be trusted to be empty for a
// binding that ships without a default combo. The parsed chord is derived
// from `combo`, so it is the single source of truth for "has no keys".
if (binding.parsedChord.length === 0) {
return null;
}
return binding.help.keys;
return displayChordForBinding(binding);
}
return null;
}
@@ -1464,8 +1493,7 @@ export function resolveShortcutKeysForAction(
return null;
}
const defaultKeys = getDefaultKeysForAction(actionId, platform);
const defaultChord = defaultKeys ? [defaultKeys] : null;
const defaultChord = getDefaultKeysForAction(actionId, platform);
const override = overrides[bindingId];
if (override === UNASSIGNED_COMBO || override === "") {
@@ -1536,13 +1564,9 @@ export function buildKeyboardShortcutHelpSections(
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
): KeyboardShortcutHelpSection[] {
const seenRows = new Set<string>();
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>([
["navigation", []],
["tabs-panes", []],
["projects", []],
["panels", []],
["agent-input", []],
]);
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>(
SHORTCUT_HELP_SECTION_ORDER.map((sectionId) => [sectionId, []]),
);
for (const binding of bindings) {
const help = binding.help;
@@ -1566,26 +1590,23 @@ export function buildKeyboardShortcutHelpSections(
id: help.id,
label: help.label,
labelKey: SHORTCUT_HELP_LABEL_KEYS[help.id] ?? help.label,
// An empty chord has no keys to show, whatever `help.keys` was authored as.
keys: binding.parsedChord.length === 0 ? [] : help.keys,
chord: displayChordForBinding(binding),
...(help.note ? { note: help.note } : {}),
...(SHORTCUT_HELP_NOTE_KEYS[help.id] ? { noteKey: SHORTCUT_HELP_NOTE_KEYS[help.id] } : {}),
});
}
const sectionOrder: ShortcutSectionId[] = [
"navigation",
"tabs-panes",
"projects",
"panels",
"agent-input",
];
return sectionOrder.flatMap((sectionId) => {
return SHORTCUT_HELP_SECTION_ORDER.flatMap((sectionId) => {
const rows = rowsBySection.get(sectionId) ?? [];
if (rows.length === 0) {
return [];
}
const order = SHORTCUT_HELP_ROW_ORDER[sectionId];
const rank = (row: KeyboardShortcutHelpRow) => {
const index = order.indexOf(row.id);
return index === -1 ? order.length : index;
};
rows.sort((left, right) => rank(left) - rank(right));
return [
{
id: sectionId,
@@ -351,6 +351,24 @@ describe("routeKeyboardShortcut — callbacks and pickers", () => {
});
describe("routeKeyboardShortcut — toggle dialogs", () => {
it("opens the command center scoped to files from a workspace", () => {
expect(
routeKeyboardShortcut({ action: "command-center.files", payload: null }, makeCtx()),
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: true, scope: "files" });
});
it("leaves the file-search shortcut to the project-picker host outside a workspace", () => {
expect(
routeKeyboardShortcut(
{ action: "command-center.files", payload: null },
makeCtx({ pathname: "/settings" }),
),
).toEqual<ShortcutAction>({
kind: "dispatch",
action: { id: "workspace.project.pick", scope: "workspace" },
});
});
it("opens the command center when closed", () => {
expect(
routeKeyboardShortcut(
+6 -1
View File
@@ -32,7 +32,7 @@ export type ShortcutAction =
| { kind: "router-push"; route: string }
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean }
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
const NONE: ShortcutAction = { kind: "none" };
@@ -198,6 +198,11 @@ export function routeKeyboardShortcut(
return routeSettingsToggle(ctx);
case "command-center.toggle":
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "command-center.files":
if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) {
return { kind: "command-center-toggle", nextOpen: true, scope: "files" };
}
return dispatch({ id: "workspace.project.pick", scope: "workspace" });
case "shortcuts.dialog.toggle":
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
default:
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
buildEffectiveBindings,
buildKeyboardShortcutHelpSections,
UNASSIGNED_COMBO,
type ShortcutOverrides,
} from "./keyboard-shortcuts";
import { filterShortcutHelpSections, shortcutSearchText } from "./shortcut-help-search";
const MAC_DESKTOP = { isMac: true, isDesktop: true };
const NEW_WORKSPACE_BINDING = "workspace-new-cmd-n-mac";
/**
* The i18n port, filled with the real English strings the rows use. Translation
* is the only dependency the filter cannot compute, so it is injected rather
* than mocked.
*/
const EN: Record<string, string> = {
"settings.shortcuts.sections.projects": "Projects",
"settings.shortcuts.help.newWorkspace": "New workspace",
"settings.shortcuts.help.openProject": "Open project",
};
function translate(key: string): string {
return EN[key] ?? key;
}
function filter(query: string, overrides: ShortcutOverrides = {}) {
return filterShortcutHelpSections({
sections: buildKeyboardShortcutHelpSections(MAC_DESKTOP, buildEffectiveBindings(overrides)),
query,
translate,
shortcutOs: "mac",
});
}
function matchedRowIds(query: string, overrides: ShortcutOverrides = {}): string[] {
return filter(query, overrides).flatMap((section) => section.rows.map((row) => row.id));
}
describe("shortcutSearchText", () => {
it("covers the tokens, the rendered badge, and the modifier aliases", () => {
const text = shortcutSearchText([["mod", "N"]], "mac");
expect(text).toContain("mod N");
expect(text).toContain("⌘N");
expect(text).toContain("cmd+N");
expect(text).toContain("command+N");
});
// The alias expansion is combinatorial, so a flattened chord would multiply
// the two combos together and invent spellings for keys never pressed at once.
it("keeps a multi-step chord's combos apart", () => {
const text = shortcutSearchText(
[
["mod", "K"],
["mod", "N"],
],
"mac",
);
expect(text).toContain("cmd+K");
expect(text).toContain("cmd+N");
expect(text).not.toContain("cmd+K+cmd+N");
});
});
describe("filterShortcutHelpSections", () => {
it("returns every section for a blank query", () => {
expect(filter(" ")).toHaveLength(buildKeyboardShortcutHelpSections(MAC_DESKTOP).length);
});
it("matches a row by its label", () => {
expect(matchedRowIds("new workspace")).toContain("new-workspace");
});
it("matches a row by the rendered badge", () => {
expect(matchedRowIds("⌘n")).toContain("new-workspace");
});
it("matches a row by a spelled-out modifier", () => {
expect(matchedRowIds("command+n")).toContain("new-workspace");
});
// The reported bug, from the search side: the cheat sheet has to stop finding
// a row by keys the user has rebound away from.
it("finds a rebound row by its new keys and not by the superseded default", () => {
const overrides = { [NEW_WORKSPACE_BINDING]: "Cmd+Shift+K" };
expect(matchedRowIds("cmd+shift+k", overrides)).toContain("new-workspace");
expect(matchedRowIds("cmd+n", overrides)).not.toContain("new-workspace");
});
it("still finds an unassigned row by its label", () => {
const overrides = { [NEW_WORKSPACE_BINDING]: UNASSIGNED_COMBO };
expect(matchedRowIds("new workspace", overrides)).toContain("new-workspace");
expect(matchedRowIds("cmd+n", overrides)).not.toContain("new-workspace");
});
it("keeps every row of a section whose own title matches", () => {
const matched = filter("layout").find((section) => section.id === "layout");
const all = buildKeyboardShortcutHelpSections(MAC_DESKTOP).find(
(section) => section.id === "layout",
);
expect(matched?.rows).toHaveLength(all?.rows.length ?? 0);
});
it("drops a section whose rows all miss", () => {
expect(filter("no shortcut spells this")).toEqual([]);
});
});
@@ -0,0 +1,90 @@
import type { KeyboardShortcutHelpSection } from "@/keyboard/keyboard-shortcuts";
import { formatShortcut, type ShortcutKey, type ShortcutOs } from "@/utils/format-shortcut";
/** Resolves an i18n key to display text. The caller's `t`, injected. */
export type TranslateHelpKey = (key: string) => string;
/**
* Alternative spellings for one combo, so searching "cmd k" and "command+k" both
* find ⌘K. The modifier a user types rarely matches the token the binding
* stores, and never matches the symbol the badge renders.
*/
export function shortcutSearchAliases(
keys: readonly ShortcutKey[],
shortcutOs: ShortcutOs,
): string {
const aliases = keys.map((key) => {
if (shortcutOs === "mac") {
if (key === "mod" || key === "meta") return ["cmd", "command"];
if (key === "alt") return ["alt", "option"];
} else {
if (key === "mod" || key === "ctrl") return ["ctrl", "control"];
if (key === "meta") return ["win", "windows"];
}
return [key];
});
const combinations = aliases.reduce<string[][]>(
(prefixes, choices) =>
prefixes.flatMap((prefix) => choices.map((choice) => [...prefix, choice])),
[[]],
);
return combinations
.flatMap((combination) => [combination.join(" "), combination.join("+")])
.join(" ");
}
/**
* Search text for one row's keys. Built per combo and joined, never over a
* flattened chord: the alias expansion is combinatorial, so flattening a
* multi-step chord into one key list both explodes the combination count and
* invents aliases for combos the user never has to press together.
*/
export function shortcutSearchText(chord: ShortcutKey[][], shortcutOs: ShortcutOs): string {
return chord
.flatMap((combo) => [
combo.join(" "),
formatShortcut(combo, shortcutOs),
shortcutSearchAliases(combo, shortcutOs),
])
.join(" ");
}
/**
* The cheat sheet's sections narrowed to a query, matching on the row's label,
* its note, and every spelling of the keys that actually fire it. A section
* whose own title matches keeps all of its rows.
*/
export function filterShortcutHelpSections({
sections,
query,
translate,
shortcutOs,
}: {
sections: readonly KeyboardShortcutHelpSection[];
query: string;
translate: TranslateHelpKey;
shortcutOs: ShortcutOs;
}): KeyboardShortcutHelpSection[] {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery) return [...sections];
return sections.flatMap((section) => {
if (translate(section.titleKey).toLocaleLowerCase().includes(normalizedQuery)) {
return [section];
}
const rows = section.rows.filter((row) => {
const searchText = [
translate(row.labelKey),
row.noteKey ? translate(row.noteKey) : row.note,
row.chord ? shortcutSearchText(row.chord, shortcutOs) : null,
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase();
return searchText.includes(normalizedQuery);
});
return rows.length > 0 ? [{ ...section, rows }] : [];
});
}
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import { formatShortcut } from "@/utils/format-shortcut";
import {
comboStringToShortcutKeys,
keyComboToString,
keyboardEventToComboString,
parseShortcutString,
SHORTCUT_KEY_NAMES,
} from "./shortcut-string";
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
@@ -68,3 +71,40 @@ describe("parseShortcutString round-trips punctuation keys", () => {
expect(keyComboToString(combo)).toBe("Cmd+-");
});
});
describe("comboStringToShortcutKeys", () => {
it("maps every modifier spelling onto its display token", () => {
expect(comboStringToShortcutKeys("Cmd+Ctrl+Alt+Shift+K")).toEqual([
"mod",
"ctrl",
"alt",
"shift",
"K",
]);
// `parseShortcutString` accepts `Mod` as well, so the display path has to.
expect(comboStringToShortcutKeys("Mod+K")).toEqual(["mod", "K"]);
});
it("hands named keys to the display table under the names it uses", () => {
expect(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft")).toEqual(["mod", "shift", "Left"]);
expect(formatShortcut(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft"), "mac")).toBe(
"Shift+⌘+←",
);
expect(formatShortcut(comboStringToShortcutKeys("Escape"), "mac")).toBe("Esc");
expect(formatShortcut(comboStringToShortcutKeys("Backspace"), "mac")).toBe("⌫");
});
// The whole class, not just the arrows: uppercasing key names used to push
// every multi-character name past `KEY_DISPLAY`, so an override on one of them
// rendered as a shouty raw code — `ARROWLEFT`, `ESCAPE`, `PAGEDOWN`.
it("never emits a shouty raw code for any key the capture path can produce", () => {
for (const name of SHORTCUT_KEY_NAMES) {
// `F1` and the digits are already all-caps, so only names that contain a
// lowercase letter can exhibit the bug.
if (name === name.toUpperCase()) continue;
const [key] = comboStringToShortcutKeys(name);
const rendered = formatShortcut([key], "non-mac");
expect(rendered, `${name} renders as ${rendered}`).not.toBe(name.toUpperCase());
}
});
});
+31 -1
View File
@@ -74,6 +74,13 @@ for (let i = 1; i <= 12; i++) {
KEY_MAP[`F${i}`] = { code: `F${i}` };
}
/**
* Every key name a combo string can carry. Exported so the display path can be
* driven over the whole vocabulary instead of a hand-copied sample that goes
* stale the moment `KEY_MAP` gains an entry.
*/
export const SHORTCUT_KEY_NAMES: readonly string[] = Object.keys(KEY_MAP);
const CODE_TO_KEY: Record<string, string> = {};
for (const [humanKey, mapping] of Object.entries(KEY_MAP)) {
if (!CODE_TO_KEY[mapping.code]) {
@@ -177,6 +184,21 @@ const MODIFIER_CODES = new Set([
"ShiftRight",
]);
/**
* Key names as combo strings spell them, mapped to the names `KEY_DISPLAY` in
* `format-shortcut` is keyed on. The two vocabularies were never reconciled:
* `keyboardEventToComboString` emits `ArrowLeft` and `Escape` (from `KEY_MAP`),
* while the display table only knows `Left` and `Esc`. Without this bridge an
* override on a named key renders as a raw code — `ARROWLEFT`, not `←`.
*/
const DISPLAY_KEY_ALIASES: Record<string, string> = {
ArrowLeft: "Left",
ArrowRight: "Right",
ArrowUp: "Up",
ArrowDown: "Down",
Escape: "Esc",
};
export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
const parts = comboString.split("+");
const keys: ShortcutKey[] = [];
@@ -185,6 +207,11 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
case "Cmd":
keys.push("mod");
break;
// `parseShortcutString` accepts `Mod`, so the display path has to as well
// or a combo it can match would render the literal word.
case "Mod":
keys.push("mod");
break;
case "Ctrl":
keys.push("ctrl");
break;
@@ -195,7 +222,10 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
keys.push("shift");
break;
default:
keys.push(part.toUpperCase());
// Pass the key through unchanged. `normalizeKey` already uppercases
// single characters, and uppercasing here is what used to push
// multi-character names past `KEY_DISPLAY` into raw output.
keys.push(DISPLAY_KEY_ALIASES[part] ?? part);
break;
}
}
@@ -3042,7 +3042,7 @@ describe("HostRuntimeStore", () => {
}
});
it("upsertDirectConnection stores SSL, password, and custom header settings", async () => {
it("upsertDirectConnection stores SSL and password settings", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
@@ -3060,7 +3060,6 @@ describe("HostRuntimeStore", () => {
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
headers: { "X-Tenant": "acme" },
label: "tls host",
});
@@ -3072,7 +3071,6 @@ describe("HostRuntimeStore", () => {
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
headers: { "X-Tenant": "acme" },
},
]);
+1 -12
View File
@@ -36,7 +36,6 @@ import {
import {
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
createDesktopWebSocketTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
@@ -479,10 +478,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const webSocketTransportFactory = createDesktopWebSocketTransportFactory();
const webSocketConfig = webSocketTransportFactory
? { transportFactory: webSocketTransportFactory }
: { webSocketFactory: createAppWebSocketFactory() };
const webSocketConfig = { webSocketFactory: createAppWebSocketFactory() };
const base = {
suppressSendErrors: true,
clientId,
@@ -510,7 +506,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
useTls: connection.useTls ?? false,
}),
...(connection.password ? { password: connection.password } : {}),
...(connection.headers ? { headers: connection.headers } : {}),
});
}
return new DaemonClient({
@@ -1651,7 +1646,6 @@ export class HostRuntimeStore {
endpoint: string;
useTls?: boolean;
password?: string;
headers?: Record<string, string>;
label?: string;
existingClient?: DaemonClient;
}): Promise<HostProfile> {
@@ -1666,7 +1660,6 @@ export class HostRuntimeStore {
endpoint,
useTls: input.useTls ?? false,
...(password ? { password } : {}),
...(input.headers ? { headers: input.headers } : {}),
},
existingClient: input.existingClient,
});
@@ -1708,7 +1701,6 @@ export class HostRuntimeStore {
endpoint: string;
useTls?: boolean;
password?: string;
headers?: Record<string, string>;
label?: string;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const endpoint = normalizeHostPort(input.endpoint);
@@ -1721,7 +1713,6 @@ export class HostRuntimeStore {
endpoint,
useTls: input.useTls ?? false,
...(password ? { password } : {}),
...(input.headers ? { headers: input.headers } : {}),
},
});
}
@@ -2463,14 +2454,12 @@ export interface HostMutations {
endpoint: string;
useTls?: boolean;
password?: string;
headers?: Record<string, string>;
label?: string;
}) => Promise<HostProfile>;
probeAndUpsertDirectConnection: (input: {
endpoint: string;
useTls?: boolean;
password?: string;
headers?: Record<string, string>;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
upsertRelayConnection: (input: {
@@ -508,7 +508,7 @@ function TabHandleContent({
return (
<View style={styles.tabHandle} dataSet={tabHandleDataSet}>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} />
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} backdrop="surface0" />
</View>
{showLabel && presentation.titleState === "loading" ? (
<View style={tabLabelSkeletonStyle} />
@@ -175,6 +175,7 @@ import {
import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import {
buildHostRootRoute,
@@ -429,10 +430,12 @@ function MobileActiveTabTrigger({
activeTab,
normalizedServerId,
normalizedWorkspaceId,
backdrop,
}: {
activeTab: WorkspaceTabDescriptor | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
backdrop: SurfaceBackdrop;
}) {
if (!activeTab) {
return null;
@@ -443,6 +446,7 @@ function MobileActiveTabTrigger({
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
backdrop={backdrop}
/>
);
}
@@ -451,10 +455,12 @@ function ResolvedMobileActiveTabTrigger({
activeTab,
normalizedServerId,
normalizedWorkspaceId,
backdrop,
}: {
activeTab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
backdrop: SurfaceBackdrop;
}) {
const { t } = useTranslation();
return (
@@ -466,7 +472,7 @@ function ResolvedMobileActiveTabTrigger({
{(presentation) => (
<>
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
<WorkspaceTabIcon presentation={presentation} active />
<WorkspaceTabIcon presentation={presentation} active backdrop={backdrop} />
</View>
<Text style={styles.switcherTriggerText} numberOfLines={1}>
@@ -741,14 +747,19 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
style={switcherTriggerStyle}
onPress={handleOpenSwitcher}
>
<View style={styles.switcherTriggerLeft}>
<MobileActiveTabTrigger
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
</View>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
{({ pressed }) => (
<>
<View style={styles.switcherTriggerLeft}>
<MobileActiveTabTrigger
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
backdrop={pressed ? "surface1" : "surface0"}
/>
</View>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</>
)}
</Pressable>
<Combobox
@@ -8,7 +8,10 @@ import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { StatusRing } from "@/components/status-ring";
import { getStatusRingOffset } from "@/components/status-ring/geometry";
import {
STATUS_INDICATOR_ALERT_SIZE,
STATUS_INDICATOR_DOT_SIZE,
@@ -112,6 +115,13 @@ interface WorkspaceTabIconProps {
active?: boolean;
size?: number;
statusDotBorderColor?: string;
/**
* The surface this icon is sitting on, so the running ring can knock out of it. This icon is
* shared by the desktop tab strip, the tab switcher trigger, the split drag chip and the
* subagents track, which are on different surfaces and some of which change surface on hover —
* it cannot work the colour out for itself.
*/
backdrop: SurfaceBackdrop;
}
const ThemedCheckIcon = withUnistyles(Check);
@@ -127,12 +137,13 @@ export function WorkspaceTabIcon({
active = false,
size = 14,
statusDotBorderColor,
backdrop,
}: WorkspaceTabIconProps): ReactElement {
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
const bucket = presentation.statusBucket;
const isRunning = bucket === "running";
let statusDotColor: string | undefined;
if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
else if (bucket === "running") statusDotColor = styles.statusDotRunning.color;
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
const showNeedsInputAlert = bucket === "needs_input";
const Icon = presentation.icon;
@@ -154,13 +165,16 @@ export function WorkspaceTabIcon({
return (
<View style={agentIconWrapperStyle}>
<Icon size={size} color={iconColor} />
{statusDotColor ? (
{isRunning ? (
<View
style={statusDotStyle}
accessibilityRole={bucket === "running" ? "progressbar" : undefined}
accessibilityLabel={bucket === "running" ? "Agent running" : undefined}
/>
style={styles.statusRing}
accessibilityRole="progressbar"
accessibilityLabel="Agent running"
>
<StatusRing backdrop={backdrop} />
</View>
) : null}
{statusDotColor ? <View style={statusDotStyle} /> : null}
{showNeedsInputAlert ? (
<View style={styles.statusAlertOverlay}>
<ThemedCircleAlert size={STATUS_INDICATOR_ALERT_SIZE} uniProps={needsInputAlertMapping} />
@@ -186,13 +200,18 @@ export function WorkspaceTabOptionRow({
trailingAccessory,
}: WorkspaceTabOptionRowProps): ReactElement {
const { t } = useTranslation();
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionMainPressable,
(Boolean(hovered) || pressed || active) && styles.optionRowActive,
],
const isOptionActive = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) =>
Boolean(hovered) || pressed || active,
[active],
);
const pressableStyle = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionMainPressable,
isOptionActive(state) && styles.optionRowActive,
],
[isOptionActive],
);
const optionRowStyle = useMemo(
() => [styles.optionRow, active && styles.optionRowActive],
[active],
@@ -200,16 +219,27 @@ export function WorkspaceTabOptionRow({
return (
<View style={optionRowStyle}>
<Pressable onPress={onPress} style={pressableStyle}>
<View style={styles.optionLeadingSlot}>
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
</View>
<View style={styles.optionContent}>
<Text numberOfLines={1} style={styles.optionLabel}>
{presentation.titleState === "loading"
? t("workspace.tabs.loading")
: presentation.label}
</Text>
</View>
{(state) => {
const optionActive = isOptionActive(state);
return (
<>
<View style={styles.optionLeadingSlot}>
<WorkspaceTabIcon
presentation={presentation}
active={selected || active}
backdrop={optionActive ? "surface1" : "surface0"}
/>
</View>
<View style={styles.optionContent}>
<Text numberOfLines={1} style={styles.optionLabel}>
{presentation.titleState === "loading"
? t("workspace.tabs.loading")
: presentation.label}
</Text>
</View>
</>
);
}}
</Pressable>
{presentation.modified ? (
<View style={styles.optionModifiedDot} accessibilityLabel={t("workspace.tabs.modified")} />
@@ -241,6 +271,11 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
statusRing: {
position: "absolute",
right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
},
statusDotBorderDefault: {
borderColor: theme.colors.surface0,
},
@@ -4,6 +4,7 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
beforeEach(() => {
useKeyboardShortcutsStore.setState({
commandCenterOpen: false,
commandCenterScope: null,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -19,6 +20,20 @@ describe("keyboard-shortcuts-store", () => {
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
});
it("opens the command center with a scope and clears it when closed", () => {
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "files");
expect(useKeyboardShortcutsStore.getState()).toMatchObject({
commandCenterOpen: true,
commandCenterScope: "files",
});
useKeyboardShortcutsStore.getState().setCommandCenterOpen(false);
expect(useKeyboardShortcutsStore.getState()).toMatchObject({
commandCenterOpen: false,
commandCenterScope: null,
});
});
it("toggles shortcut capture state", () => {
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(false);
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
@@ -3,8 +3,11 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
const SHORTCUT_BADGE_DELAY_MS = 150;
export type CommandCenterScope = "files" | null;
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
commandCenterScope: CommandCenterScope;
shortcutsDialogOpen: boolean;
capturingShortcut: boolean;
altDown: boolean;
@@ -13,7 +16,8 @@ interface KeyboardShortcutsState {
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
setCommandCenterOpen: (open: boolean) => void;
setCommandCenterOpen: (open: boolean, scope?: CommandCenterScope) => void;
setCommandCenterScope: (scope: CommandCenterScope) => void;
setShortcutsDialogOpen: (open: boolean) => void;
setCapturingShortcut: (capturing: boolean) => void;
setAltDown: (down: boolean) => void;
@@ -47,6 +51,7 @@ function updateBadgeTimer(
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, get) => ({
commandCenterOpen: false,
commandCenterScope: null,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -54,7 +59,9 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, ge
showShortcutBadges: false,
sidebarShortcutWorkspaceTargets: [],
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setCommandCenterOpen: (open, scope = null) =>
set({ commandCenterOpen: open, commandCenterScope: open ? scope : null }),
setCommandCenterScope: (scope) => set({ commandCenterScope: scope }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
setAltDown: (down) => {
@@ -77,8 +77,8 @@ describe("createMarkdownStyles", () => {
expect(styles.code_inline).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
lineHeight: Math.round(darkTheme.fontSize.code * 1.45),
});
expect(styles.code_inline).not.toHaveProperty("lineHeight");
expect(styles.code_block).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
@@ -90,7 +90,7 @@ describe("createMarkdownStyles", () => {
expect(compactStyles.code_inline).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
lineHeight: Math.round(darkTheme.fontSize.code * 1.45),
});
expect(compactStyles.code_inline).not.toHaveProperty("lineHeight");
});
});
@@ -172,7 +172,6 @@ export function createMarkdownStyles(theme: Theme) {
borderWidth: 0,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: Math.round(theme.fontSize.code * 1.45),
},
code_block: {
+11 -1
View File
@@ -11,4 +11,14 @@
* `StyleSheet.create` — see docs/unistyles.md. Each knockout maps the name to a style of its own.
* Add a token here when a knockout lands on a surface that isn't listed.
*/
export type SurfaceBackdrop = "surfaceSidebar" | "surfaceSidebarHover" | "surface2";
export type SurfaceBackdrop =
| "surface0"
| "surface1"
| "surface2"
| "surfaceSidebar"
| "surfaceSidebarHover";
export type SidebarSurfaceBackdrop = Extract<
SurfaceBackdrop,
"surfaceSidebar" | "surfaceSidebarHover" | "surface2"
>;
+20 -11
View File
@@ -165,11 +165,18 @@ const darkStatusColors = {
// for them. Same four hues and the same generation rule as the status colors above, but its
// own band, because a dot is doing a different job than a check icon or a host badge.
//
// A dot is 6-8pt of solid color with no shape to read and no label attached. At the status
// A dot is 6pt of solid color with no shape to read and no label attached. At the status
// band's lightness the dots read dimmer than the static text and icons beside them on the same
// row, which is backwards — the dot is the row's state. So the band is pushed away from the
// surface rather than toward it (darker in light, lighter in dark) and carries more chroma:
// 90% of gamut max against the status family's 55-60%.
// row, which is backwards — the dot is the row's state. The loudness comes from chroma: 90% of
// gamut max against the status family's 55-60%.
//
// Lightness is set by hue separation, not by distance from the surface. A dark dot on a light
// surface has plenty of contrast but the four hues collapse into each other at 6pt — dark green,
// dark red, dark amber and dark blue all read as "dark blob", and the point of the dot is telling
// them apart at a glance. So the light band runs as bright as the contrast floor allows: L=0.62
// is the last step where all four clear 3:1 against the sidebar's surface2 (success is the
// binding one at 3.10, and drops under 3 by L=0.64), which is WCAG's non-text minimum for a
// control that carries state.
//
// All four move together. A dot matching its siblings in lightness and chroma says only which
// state the row is in; one that does not says "this row matters more", which is a claim the
@@ -178,14 +185,16 @@ const darkStatusColors = {
// 90% and not 100%: at the gamut edge the lopsidedness is worst — green reaches C=0.215 while
// blue manages 0.116 — so the set stops reading as one family and green wins. Red running out
// of chroma as lightness climbs is what caps the dark band at L=0.72; higher turns the failed
// dot pink. Running is blue at hue 250, clear of identity-colors' blue at 256.6 so a blue host
// badge and a working dot on the same row do not read as related.
// dot pink, and the light band pastels out the same way just above its own cap. Running is blue
// at hue 250, clear of
// identity-colors' blue at 256.6 so a blue host badge and a working dot on the same row do not
// read as related.
const lightStatusDotColors = {
// L=0.46, chroma 90% of gamut max
statusDotSuccess: "#186933",
statusDotDanger: "#a11c1c",
statusDotWarning: "#774e14",
statusDotRunning: "#165a96",
// L=0.62, chroma 90% of gamut max
statusDotSuccess: "#299f51",
statusDotDanger: "#f12e2f",
statusDotWarning: "#b37824",
statusDotRunning: "#268ae0",
};
const darkStatusDotColors = {
+4 -1
View File
@@ -200,7 +200,10 @@ function SubagentsTrackRow({
>
{({ pressed }) => (
<View style={hovered || pressed ? styles.rowActive : styles.row}>
<WorkspaceTabIcon presentation={presentation} />
<WorkspaceTabIcon
presentation={presentation}
backdrop={hovered || pressed ? "surface2" : "surface1"}
/>
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
+12 -81
View File
@@ -75,49 +75,6 @@ describe("normalizeStoredHostProfile", () => {
expect(profile?.connections[0]).not.toHaveProperty("password");
});
it("preserves custom headers on stored direct TCP connections", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_headers",
connections: [
{
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
headers: { "X-Tenant": "acme" },
},
],
});
expect(profile?.connections[0]).toEqual({
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
useTls: false,
headers: { "X-Tenant": "acme" },
});
});
it("keeps the connection but discards malformed stored custom headers", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_malformed_headers",
connections: [
{
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
headers: { "X-Tenant": 42 },
},
],
});
expect(profile?.connections[0]).toEqual({
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
useTls: false,
});
});
it("preserves legacy relay ids when TLS is absent", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_relay",
@@ -219,59 +176,33 @@ describe("upsertHostConnectionInProfiles", () => {
expect(profile.appearance).toEqual({ color: "amber", badgeDisplay: "hidden" });
});
it("deduplicates matching custom headers regardless of key order", () => {
const firstConnection: HostConnection = {
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
headers: { "X-One": "1", "X-Two": "2" },
};
const existing = {
...makeHost("srv_known"),
connections: [firstConnection],
};
const [profile] = upsertHostConnectionInProfiles({
profiles: [existing],
serverId: "srv_known",
connection: {
...firstConnection,
headers: { "X-Two": "2", "X-One": "1" },
},
});
expect(profile.connections).toHaveLength(1);
});
it("replaces a direct connection when its custom headers change", () => {
it("replaces a direct connection when its settings change", () => {
const existingConnection: HostConnection = {
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
headers: { "X-Tenant": "old" },
useTls: false,
password: "old-secret",
};
const existing = {
const existing: HostProfile = {
...makeHost("srv_known"),
connections: [existingConnection],
preferredConnectionId: existingConnection.id,
};
const replacement: HostConnection = {
...existingConnection,
useTls: true,
password: "new-secret",
};
const [profile] = upsertHostConnectionInProfiles({
profiles: [existing],
serverId: "srv_known",
connection: {
...existingConnection,
headers: { "X-Tenant": "new" },
},
connection: replacement,
});
expect(profile.connections).toEqual([
{
...existingConnection,
headers: { "X-Tenant": "new" },
},
]);
expect(profile.preferredConnectionId).toBe(existingConnection.id);
expect(profile.connections).toEqual([replacement]);
expect(profile.preferredConnectionId).toBe(replacement.id);
});
});
+1 -21
View File
@@ -11,7 +11,6 @@ import {
defaultHostAppearance,
normalizeStoredHostAppearance,
} from "@/hosts/appearance";
import { normalizeConnectionHeadersRecord } from "@/utils/connection-headers";
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
@@ -105,18 +104,6 @@ export function resolveActiveHostServerId(params: {
);
}
function stringRecordEquals(
left: Record<string, string> | undefined,
right: Record<string, string> | undefined,
): boolean {
const leftEntries = Object.entries(left ?? {});
const rightRecord = right ?? {};
return (
leftEntries.length === Object.keys(rightRecord).length &&
leftEntries.every(([key, value]) => rightRecord[key] === value)
);
}
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
if (left.type !== right.type || left.id !== right.id) {
return false;
@@ -126,8 +113,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
return (
left.endpoint === right.endpoint &&
(left.useTls ?? false) === (right.useTls ?? false) &&
left.password === right.password &&
stringRecordEquals(left.headers, right.headers)
left.password === right.password
);
}
if (left.type === "directSocket" && right.type === "directSocket") {
@@ -315,11 +301,6 @@ function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isPlainRecord(value) ? value : undefined;
}
function normalizeStoredHeaders(value: unknown): { headers?: Record<string, string> } {
const headers = normalizeConnectionHeadersRecord(value);
return headers ? { headers } : {};
}
function normalizeStoredConnection(connection: unknown): HostConnection | null {
const record = toObjectRecord(connection);
if (!record) {
@@ -337,7 +318,6 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
endpoint,
useTls: record.useTls,
...(typeof record.password === "string" ? { password: record.password } : {}),
...normalizeStoredHeaders(record.headers),
});
} catch {
return null;
@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import { normalizeConnectionHeadersRecord, prepareConnectionHeaders } from "./connection-headers";
describe("prepareConnectionHeaders", () => {
it("trims and returns custom headers", () => {
expect(
prepareConnectionHeaders([
{ id: 1, name: " X-Tenant ", value: " acme " },
{ id: 2, name: "X-Empty", value: "" },
]),
).toEqual({ headers: { "X-Tenant": "acme", "X-Empty": "" } });
});
it("ignores completely empty draft rows", () => {
expect(prepareConnectionHeaders([{ id: 1, name: "", value: "" }])).toEqual({});
});
it("requires a name when a value is entered", () => {
expect(prepareConnectionHeaders([{ id: 1, name: "", value: "acme" }])).toEqual({
issue: { type: "missingName" },
});
});
it("rejects invalid and duplicate names case-insensitively", () => {
expect(prepareConnectionHeaders([{ id: 1, name: "Bad Header", value: "value" }])).toEqual({
issue: { type: "invalidName", name: "Bad Header" },
});
expect(
prepareConnectionHeaders([
{ id: 1, name: "X-Tenant", value: "one" },
{ id: 2, name: "x-tenant", value: "two" },
]),
).toEqual({ issue: { type: "duplicateName", name: "x-tenant" } });
});
it("rejects line breaks in values", () => {
expect(prepareConnectionHeaders([{ id: 1, name: "X-Test", value: "one\ntwo" }])).toEqual({
issue: { type: "invalidValue", name: "X-Test" },
});
});
});
describe("normalizeConnectionHeadersRecord", () => {
it("accepts a valid persisted string record", () => {
expect(normalizeConnectionHeadersRecord({ "X-Tenant": "acme" })).toEqual({
"X-Tenant": "acme",
});
});
it("rejects malformed persisted header values", () => {
expect(normalizeConnectionHeadersRecord({ "X-Tenant": 42 })).toBeUndefined();
expect(
normalizeConnectionHeadersRecord({ "X-Tenant": "acme\r\nX-Injected: true" }),
).toBeUndefined();
});
});
@@ -1,65 +0,0 @@
export interface ConnectionHeaderDraft {
id: number;
name: string;
value: string;
}
export type ConnectionHeaderValidationIssue =
| { type: "missingName" }
| { type: "invalidName"; name: string }
| { type: "invalidValue"; name: string }
| { type: "duplicateName"; name: string };
const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function prepareConnectionHeaders(drafts: readonly ConnectionHeaderDraft[]): {
headers?: Record<string, string>;
issue?: ConnectionHeaderValidationIssue;
} {
const headers: Record<string, string> = {};
const normalizedNames = new Set<string>();
for (const draft of drafts) {
const name = draft.name.trim();
const value = draft.value.trim();
if (!name && !value) continue;
if (!name) return { issue: { type: "missingName" } };
if (!HTTP_HEADER_NAME_PATTERN.test(name)) {
return { issue: { type: "invalidName", name } };
}
if (/\r|\n/.test(value)) {
return { issue: { type: "invalidValue", name } };
}
const normalizedName = name.toLowerCase();
if (normalizedNames.has(normalizedName)) {
return { issue: { type: "duplicateName", name } };
}
normalizedNames.add(normalizedName);
headers[name] = value;
}
return Object.keys(headers).length > 0 ? { headers } : {};
}
export function normalizeConnectionHeadersRecord(
value: unknown,
): Record<string, string> | undefined {
if (!isPlainRecord(value)) {
return undefined;
}
const drafts: ConnectionHeaderDraft[] = [];
for (const [name, headerValue] of Object.entries(value)) {
if (typeof headerValue !== "string") {
return undefined;
}
drafts.push({ id: drafts.length, name, value: headerValue });
}
const result = prepareConnectionHeaders(drafts);
return result.issue ? undefined : result.headers;
}
@@ -32,9 +32,3 @@ export function getStatusDotColor(input: {
}
return null;
}
export function isEmphasizedStatusDotBucket(
bucket: SidebarStateBucket | null | undefined,
): boolean {
return bucket === "needs_input" || bucket === "attention";
}
@@ -1,2 +1,3 @@
export const STATUS_INDICATOR_DOT_SIZE = 8;
export const STATUS_INDICATOR_FILLED_DOT_SIZE = 6;
export const STATUS_INDICATOR_DOT_SIZE = STATUS_INDICATOR_FILLED_DOT_SIZE + 2;
export const STATUS_INDICATOR_ALERT_SIZE = 12;
@@ -101,6 +101,29 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(probe.clientIdsRequested).toBe(2);
});
it("keeps direct TCP probes on the renderer WebSocket", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const deps = {
...probe.deps,
createWebSocketTransportFactory: () => {
throw new Error("Direct TCP must not use the desktop WebSocket bridge");
},
};
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
undefined,
deps,
);
await result.client.close();
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
});
it("encodes the local socket target into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
@@ -134,46 +157,6 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
});
it("passes direct TCP custom headers into the initial probe client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
headers: { "X-Tenant": "acme" },
},
undefined,
probe.deps,
);
await result.client.close();
expect(probe.createdConfigs()[0]?.headers).toEqual({ "X-Tenant": "acme" });
});
it("uses the Electron WebSocket transport for the initial probe", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const transportFactory: NonNullable<DaemonClientConfig["transportFactory"]> = () => {
throw new Error("The fake probe client should not open the transport.");
};
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
headers: { "X-Tenant": "acme" },
},
undefined,
{
...probe.deps,
createWebSocketTransportFactory: () => transportFactory,
},
);
await result.client.close();
expect(probe.createdConfigs()[0]?.transportFactory).toBe(transportFactory);
});
it("passes performance tracing into the connected client", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const trace = {
@@ -11,7 +11,6 @@ import {
import {
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
createDesktopWebSocketTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
export interface DaemonProbeClient {
@@ -30,7 +29,6 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
createWebSocketTransportFactory?(): DaemonClientConfig["transportFactory"] | null;
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
createClient(config: DaemonClientConfig): TClient;
}
@@ -39,17 +37,10 @@ const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonCl
getClientId: getOrCreateClientId,
resolveAppVersion,
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
createWebSocketTransportFactory: createDesktopWebSocketTransportFactory,
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
createClient: (config) => new DaemonClient(config),
};
function resolveWebSocketTransportFactory(
deps: Pick<DaemonConnectionDependencies<DaemonProbeClient>, "createWebSocketTransportFactory">,
): DaemonClientConfig["transportFactory"] {
return deps.createWebSocketTransportFactory?.() ?? undefined;
}
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
@@ -112,11 +103,7 @@ export async function buildClientConfig(
},
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
| "getClientId"
| "resolveAppVersion"
| "createLocalTransportFactory"
| "createWebSocketTransportFactory"
| "buildLocalTransportUrl"
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
> = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> {
const clientId = await deps.getClientId();
@@ -148,10 +135,8 @@ export async function buildClientConfig(
if (connection.type === "directTcp") {
return {
...base,
transportFactory: resolveWebSocketTransportFactory(deps),
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
...(connection.password ? { password: connection.password } : {}),
...(connection.headers ? { headers: connection.headers } : {}),
};
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.3.0",
"version": "0.3.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.3.0",
"@getpaseo/protocol": "0.3.0",
"@getpaseo/server": "0.3.0",
"@getpaseo/client": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/server": "0.3.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+50 -2
View File
@@ -2,7 +2,8 @@ import { strict as assert } from "node:assert";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, it } from "vitest";
import { HubHttpClient } from "./client.js";
import { HubHttpClient } from "./hub-client/index.js";
import { HubCommandError } from "./error.js";
const servers: Array<ReturnType<typeof createServer>> = [];
@@ -89,11 +90,56 @@ describe("Hub HTTP client", () => {
["/api/v1/projects", "/api/v1/daemons/enrollment-tokens"],
);
});
it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => {
const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer(
() => ({
status: 422,
contentType: "application/problem+json",
body: {
type: "https://hub.test/problems/invalid-configuration-bundle",
title: "Invalid configuration bundle",
status: 422,
detail: "Correct the canonical Hub bundle files. operator-secret",
code: "invalid_configuration_bundle",
requestId: "request-1",
issues: [
{
path: [".paseo/workflows/answer.yml", "steps", "work", "agent"],
message: "unknown named agent operator-secret",
},
],
},
}),
requests,
);
const hub = new HubHttpClient();
await assert.rejects(
hub.validateConfiguration({
origin,
apiKey: "operator-secret",
projectSlug: "studio",
files: [{ path: ".paseo/hub.yml", content: "sensitive bundle content" }],
}),
(error: unknown) => {
assert.ok(error instanceof HubCommandError);
assert.equal(error.message.includes("operator-secret"), false);
assert.equal(error.details?.includes("operator-secret"), false);
assert.equal(error.message.includes("sensitive bundle content"), false);
assert.equal(error.details?.includes("sensitive bundle content"), false);
assert.match(error.details ?? "", /\.paseo\/workflows\/answer\.yml\.steps\.work\.agent/u);
return true;
},
);
});
});
interface TestResponse {
status: number;
body: unknown;
contentType?: string;
}
async function startServer(
@@ -109,7 +155,9 @@ async function startServer(
request.on("end", () => {
requests.push({ url: request.url, body });
const configured = responseFor(request.url);
response.writeHead(configured.status, { "content-type": "application/json" });
response.writeHead(configured.status, {
"content-type": configured.contentType ?? "application/json",
});
response.end(JSON.stringify(configured.body));
});
});
-296
View File
@@ -1,296 +0,0 @@
import { z } from "zod";
import type { HubDeployPartial } from "./deploy-input.js";
import { HubCommandError } from "./error.js";
const activationUrlSchema = z.url({ protocol: /^https?$/u });
const authorizationSchema = z
.object({
deviceCode: z.string().min(32),
userCode: z.string().min(1),
verificationUri: activationUrlSchema,
verificationUriComplete: activationUrlSchema,
expiresAt: z.string().datetime(),
interval: z.number().int().min(1),
})
.strict();
const authorizationPollSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("pending"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("slow_down"), interval: z.number().int().min(1) }).strict(),
z
.object({
status: z.literal("authorized"),
interval: z.number().int().min(1),
credential: z.string().min(32),
organizationId: z.string().min(1),
})
.strict(),
z.object({ status: z.literal("denied"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("expired"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("disclosed"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("retry_later") }).strict(),
]);
const projectSchema = z
.object({ id: z.string().uuid(), slug: z.string().min(1), name: z.string().min(1) })
.strict();
const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict();
const installResponseSchema = z
.object({
projectSlug: z.string().min(1),
version: z.number().int().positive(),
versionId: z.string().uuid(),
active: z.literal(true),
})
.strict();
const validationResponseSchema = z
.object({ projectSlug: z.string().min(1), valid: z.literal(true) })
.strict();
const enrollmentTokenSchema = z
.object({ token: z.string().min(32), expiresAt: z.string().datetime() })
.strict();
const issuePathSchema = z.union([z.string(), z.array(z.union([z.string(), z.number()]))]);
const fieldIssueSchema = z.object({
field: z.string().optional(),
path: issuePathSchema.optional(),
message: z.string(),
});
const problemSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
status: z.number().int().optional(),
detail: z.string().optional(),
instance: z.string().optional(),
errors: z
.union([z.array(fieldIssueSchema), z.record(z.string(), z.array(z.string()))])
.optional(),
issues: z.array(fieldIssueSchema).optional(),
});
export type CliAuthorization = z.infer<typeof authorizationSchema>;
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
export type HubProject = z.infer<typeof projectSchema>;
export type HubInstallResult = z.infer<typeof installResponseSchema>;
export type HubValidationResult = z.infer<typeof validationResponseSchema>;
interface HubConfigurationInput {
origin: string;
apiKey: string;
projectSlug: string;
yaml: string;
partials?: readonly HubDeployPartial[];
}
export class HubHttpClient {
async startCliAuthorization(origin: string): Promise<CliAuthorization> {
return this.request({
origin,
path: "/api/v1/cli-authorizations",
method: "POST",
body: {},
successStatus: 201,
schema: authorizationSchema,
failureMessage: "Hub CLI login could not be started",
});
}
async pollCliAuthorization(
origin: string,
deviceCode: string,
timeoutMilliseconds: number,
): Promise<CliAuthorizationPoll> {
try {
return await this.request({
origin,
path: "/api/v1/cli-authorizations/poll",
method: "POST",
body: { deviceCode },
successStatus: 200,
schema: authorizationPollSchema,
timeoutMilliseconds,
failureMessage: "Hub CLI login polling failed",
});
} catch (error) {
if (error instanceof HubCommandError && error.code === "HUB_NETWORK_ERROR") {
return { status: "retry_later" };
}
throw error;
}
}
async listProjects(origin: string, apiKey: string): Promise<HubProject[]> {
const response = await this.request({
origin,
path: "/api/v1/projects",
method: "GET",
apiKey,
successStatus: 200,
schema: projectsResponseSchema,
failureMessage: "Hub project listing failed",
});
return response.projects;
}
installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
return this.request({
origin: input.origin,
path: "/api/v1/configurations/install",
method: "POST",
apiKey: input.apiKey,
body: configurationBody(input),
successStatus: 201,
schema: installResponseSchema,
failureMessage: "Hub deployment failed",
});
}
validateConfiguration(input: HubConfigurationInput): Promise<HubValidationResult> {
return this.request({
origin: input.origin,
path: "/api/v1/configurations/validate",
method: "POST",
apiKey: input.apiKey,
body: configurationBody(input),
successStatus: 200,
schema: validationResponseSchema,
failureMessage: "Hub configuration validation failed",
});
}
async issueEnrollmentToken(origin: string, apiKey: string): Promise<string> {
const response = await this.request({
origin,
path: "/api/v1/daemons/enrollment-tokens",
method: "POST",
apiKey,
successStatus: 201,
schema: enrollmentTokenSchema,
failureMessage: "Hub daemon enrollment authorization failed",
});
return response.token;
}
private async request<T>(input: RequestInput<T>): Promise<T> {
const signal = AbortSignal.timeout(input.timeoutMilliseconds ?? 15_000);
let response: Response;
try {
response = await fetch(`${input.origin}${input.path}`, {
method: input.method,
headers: {
...(input.apiKey === undefined ? {} : { authorization: `Bearer ${input.apiKey}` }),
...(input.body === undefined ? {} : { "content-type": "application/json" }),
},
...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
signal,
});
} catch {
throw new HubCommandError(
"HUB_NETWORK_ERROR",
`Could not reach Paseo Hub at ${input.origin}. Check the Hub URL and network connection.`,
);
}
if (response.status !== input.successStatus) {
throw await requestFailure(response, input.failureMessage, input.apiKey);
}
try {
return input.schema.parse(await response.json());
} catch {
throw new HubCommandError("HUB_INVALID_RESPONSE", "Hub returned a malformed response.");
}
}
}
interface RequestInput<T> {
origin: string;
path: string;
method: "GET" | "POST";
apiKey?: string;
body?: unknown;
successStatus: number;
schema: z.ZodType<T>;
timeoutMilliseconds?: number;
failureMessage: string;
}
function configurationBody(input: HubConfigurationInput): object {
return {
projectSlug: input.projectSlug,
yaml: input.yaml,
...(input.partials === undefined || input.partials.length === 0
? {}
: { partials: input.partials }),
};
}
async function requestFailure(
response: Response,
failureMessage: string,
apiKey: string | undefined,
): Promise<HubCommandError> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/problem+json")) {
return new HubCommandError(
"HUB_REQUEST_FAILED",
`${failureMessage} with HTTP ${response.status}.`,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
return new HubCommandError(
"HUB_INVALID_RESPONSE",
`Hub returned malformed problem details for HTTP ${response.status}.`,
);
}
const parsed = problemSchema.safeParse(body);
if (
!parsed.success ||
(parsed.data.status !== undefined && parsed.data.status !== response.status)
) {
return new HubCommandError(
"HUB_INVALID_RESPONSE",
`Hub returned nonconforming problem details for HTTP ${response.status}.`,
);
}
const title = parsed.data.title ?? `${failureMessage} with HTTP ${response.status}`;
const message = parsed.data.detail === undefined ? title : `${title}: ${parsed.data.detail}`;
const details = formatFieldIssues(parsed.data.errors, parsed.data.issues);
const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED";
return new HubCommandError(
code,
redactSecret(message, apiKey),
details === undefined ? undefined : redactSecret(details, apiKey),
);
}
function formatFieldIssues(
errors: z.infer<typeof problemSchema>["errors"],
issues: z.infer<typeof problemSchema>["issues"],
): string | undefined {
const fieldIssues = Array.isArray(errors) ? errors : issues;
if (fieldIssues !== undefined) {
const lines = fieldIssues.map((issue) => {
const field = issue.field ?? formatIssuePath(issue.path);
return field === undefined ? issue.message : `${field}: ${issue.message}`;
});
return lines.length === 0 ? undefined : lines.join("\n");
}
if (errors === undefined) return undefined;
const lines = Object.entries(errors).flatMap(([field, messages]) =>
messages.map((message: string) => `${field}: ${message}`),
);
return lines.length === 0 ? undefined : lines.join("\n");
}
function formatIssuePath(path: z.infer<typeof issuePathSchema> | undefined): string | undefined {
if (path === undefined || typeof path === "string") return path;
let formatted = "";
for (const segment of path) {
if (typeof segment === "number") formatted += `[${segment}]`;
else formatted += formatted.length === 0 ? segment : `.${segment}`;
}
return formatted || undefined;
}
function redactSecret(value: string, secret: string | undefined): string {
return secret === undefined ? value : value.split(secret).join("[redacted]");
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { resolveHubCredential, resolveHubOrigin } from "./authority.js";
import type { HubHttpClient } from "./client.js";
import type { HubHttpClient } from "./hub-client/index.js";
import type { HubCredentialStore } from "./credentials.js";
import type { HubDaemonConnection } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js";
@@ -0,0 +1,220 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { discoverHubBundle } from "./deploy-bundle.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("Hub deployment bundle discovery", () => {
it("discovers the canonical bundle in deterministic path order", async () => {
const cwd = await canonicalProject();
await writeFile(
path.join(cwd, ".paseo", "workflows", "z-last.yml"),
workflow("z-last", "codex-safe"),
);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).resolves.toEqual({
projectSlug: "studio-api",
workflowCount: 2,
files: [
{ path: ".paseo/hub.yml", content: hubResource },
{
path: ".paseo/workflows/answer.yml",
content: workflow("answer", "${{ paseo.inputs.agent }}", true),
},
{
path: ".paseo/workflows/partials/safety.md",
content: "Keep the request in paseo.prompt and evidence in paseo.context.\n",
},
{ path: ".paseo/workflows/z-last.yml", content: workflow("z-last", "codex-safe") },
],
});
});
it.each([
{
name: "missing hub.yml",
arrange: async (cwd: string) => {
await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true });
},
code: "HUB_RESOURCE_MISSING",
message: ".paseo/hub.yml does not exist",
},
{
name: "missing workflow directory",
arrange: async (cwd: string) => {
await mkdir(path.join(cwd, ".paseo"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), hubResource);
},
code: "HUB_WORKFLOW_DIRECTORY_MISSING",
message: ".paseo/workflows does not exist",
},
{
name: "empty workflow directory",
arrange: async (cwd: string) => {
await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), hubResource);
},
code: "HUB_WORKFLOW_MISSING",
message: ".paseo/workflows must contain at least one direct-child .yml workflow",
},
{
name: "unsupported workflow extension",
arrange: async (cwd: string) => {
await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), hubResource);
await writeFile(path.join(cwd, ".paseo", "workflows", "answer.yaml"), "name: answer\n");
},
code: "HUB_WORKFLOW_EXTENSION_UNSUPPORTED",
message: ".paseo/workflows/answer.yaml must use the .yml extension",
},
])("rejects $name before contacting Hub", async ({ arrange, code, message }) => {
const cwd = await temporaryDirectory();
await arrange(cwd);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code,
message: expect.stringContaining(message),
});
});
it("rejects prompt partial traversal with a path-specific diagnostic", async () => {
const cwd = await canonicalProject();
await writeFile(
path.join(cwd, ".paseo", "workflows", "answer.yml"),
workflow("answer", "codex-safe").replace(
" - text: ${{ paseo.prompt }}",
" - include: ../secret.md",
),
);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code: "HUB_PARTIAL_PATH_INVALID",
message: expect.stringContaining(".paseo/workflows/answer.yml"),
});
});
it("reports a referenced partial that is missing from the bundle", async () => {
const cwd = await canonicalProject();
await writeFile(
path.join(cwd, ".paseo", "workflows", "answer.yml"),
workflow("answer", "codex-safe").replace(
" - text: ${{ paseo.prompt }}",
" - include: partials/missing.md",
),
);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code: "HUB_BUNDLE_FILE_MISSING",
message: expect.stringContaining(".paseo/workflows/partials/missing.md"),
});
});
it("rejects nested workflow files instead of discovering a second layout", async () => {
const cwd = await canonicalProject();
await mkdir(path.join(cwd, ".paseo", "workflows", "nested"));
await writeFile(
path.join(cwd, ".paseo", "workflows", "nested", "other.yml"),
workflow("other", "codex-safe"),
);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code: "HUB_WORKFLOW_PATH_UNSUPPORTED",
message: expect.stringContaining(".paseo/workflows/nested"),
});
});
it("rejects symlinked workflow and partial paths without reading their targets", async () => {
const cwd = await canonicalProject();
const outside = path.join(cwd, "outside.yml");
await writeFile(outside, workflow("linked", "codex-safe"));
await symlink(outside, path.join(cwd, ".paseo", "workflows", "linked.yml"));
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code: "HUB_BUNDLE_UNSAFE_PATH",
message: expect.stringContaining(".paseo/workflows/linked.yml"),
});
});
it("requires an explicit valid project slug outside the authored bundle", async () => {
const cwd = await canonicalProject();
await expect(discoverHubBundle({ cwd })).rejects.toMatchObject({
code: "HUB_PROJECT_REQUIRED",
});
await expect(discoverHubBundle({ cwd, project: "Studio API" })).rejects.toMatchObject({
code: "HUB_INVALID_PROJECT",
});
});
});
const hubResource = [
"environments:",
" studio:",
" kind: daemon",
" daemon: local",
" cwd: /workspace/studio",
"agents:",
" codex-safe:",
" provider: codex",
" model: gpt-5.5",
" thinkingOptionId: xhigh",
" options:",
" sandbox_workspace_write:",
" writable_roots: [/var/cache/npm]",
" network_access: false",
" claude:",
" provider: claude",
" mode: bypassPermissions",
"",
].join("\n");
function workflow(name: string, agent: string, include = false): string {
return [
`name: ${name}`,
"on: manual.run",
"max_runtime: 1h",
...(agent.includes("paseo.inputs")
? ["inputs:", " agent:", " type: string", " choices: [codex-safe, claude]"]
: []),
"steps:",
" - id: work",
" environment: studio",
" max_runtime: 30m",
" idle_timeout: 5m",
` agent: ${agent}`,
" prompt:",
...(include ? [" - include: partials/safety.md"] : []),
" - text: ${{ paseo.prompt }}",
"",
].join("\n");
}
async function canonicalProject(): Promise<string> {
const cwd = await temporaryDirectory();
const workflows = path.join(cwd, ".paseo", "workflows");
await mkdir(path.join(workflows, "partials"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), hubResource);
await writeFile(
path.join(workflows, "answer.yml"),
workflow("answer", "${{ paseo.inputs.agent }}", true),
);
await writeFile(
path.join(workflows, "partials", "safety.md"),
"Keep the request in paseo.prompt and evidence in paseo.context.\n",
);
return cwd;
}
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-bundle-"));
temporaryDirectories.push(directory);
return directory;
}

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