mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7533e8330 | ||
|
|
a98d154b6b | ||
|
|
42f1d0b289 | ||
|
|
48e65b407f | ||
|
|
7954babca6 | ||
|
|
05a0cabc07 | ||
|
|
b7f137c2a0 | ||
|
|
02c4bc76a6 | ||
|
|
ccb4e444ed | ||
|
|
b0a7d493aa | ||
|
|
bfec7ac3ad | ||
|
|
5269c7b592 | ||
|
|
05dc2022c8 | ||
|
|
c0daf8e066 | ||
|
|
6ae6231d1f | ||
|
|
c77a6761af | ||
|
|
fd9cc5d693 | ||
|
|
7761d122d8 | ||
|
|
01a1d3b2b6 | ||
|
|
3bca9b7a92 | ||
|
|
8ffe7c75d5 | ||
|
|
74cf1931b1 |
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -1 +1 @@
|
||||
sha256-RCp5Ogd8AETLmJCZaUebcgSPRk+is26nkUY7+blDb/g=
|
||||
sha256-tB0WZG6F6VFMS5zFqo73giSAn7SqfkJISF5fMjDioJQ=
|
||||
|
||||
Generated
+80
-296
@@ -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
@@ -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,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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: "تبديل الشريط الجانبي الأيسر",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "左サイドバーを切り替え",
|
||||
|
||||
@@ -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: "왼쪽 사이드바 토글",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Переключить левую боковую панель",
|
||||
|
||||
@@ -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: "切换左侧边栏",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
>;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user